diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 227aee8ac9..26eca62464 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,6 +13,7 @@ next to them. | `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) | | `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | @@ -26,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{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/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -41,6 +42,7 @@ next to them. | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -112,6 +114,6 @@ next to the files: | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | -| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | +| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. diff --git a/Cargo.lock b/Cargo.lock index e7bfc1112c..54085cf447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4613,7 +4613,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.2" +version = "0.9.0" dependencies = [ "arboard", "async-io", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.2" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4667,7 +4667,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4680,7 +4680,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4702,7 +4702,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4716,7 +4716,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.2" +version = "0.9.0" dependencies = [ "moxcms", "rayon", @@ -4724,7 +4724,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.2" +version = "0.9.0" dependencies = [ "exr", "flate2", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-cms", "serde", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.2" +version = "0.9.0" dependencies = [ "criterion", "photocraft-color", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4779,7 +4779,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "craft-print", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4835,14 +4835,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.2" +version = "0.9.0" dependencies = [ "half", "log", @@ -4859,14 +4859,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.2" +version = "0.9.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4895,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4928,7 +4928,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "photocraft-psd", @@ -4939,7 +4939,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.2" +version = "0.9.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.2" +version = "0.9.0" dependencies = [ "memory-stats", "photocraft-color", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "parley", @@ -5001,7 +5001,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5042,7 +5042,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.2" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -8651,7 +8651,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2c329d980d..663d268c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.8.2" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index b272705e40..d1e839323c 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. -On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. +On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a81cce61ce..b93d4ccc8f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R { .map(|v| v.iter().collect::>()) .unwrap_or_default() .into_iter() - .filter(|c| { - needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n)) - }) + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) .collect(); if a.has("--json") { return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 231eb2c321..3c28a8196e 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() { // Every flag still works in both forms. let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + for needle in ["ellipse", "marquee"] { + let (out, _) = ok(bin().args(["commands", "--filter", needle])); + assert!(out.contains("select.rect"), "{needle}: {out}"); + } ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ "--format=jpg", "--quality", diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..d5004334c1 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -340,20 +340,7 @@ fn services(inbox: Inbox) -> Services { photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { - let mut opts = photocraft_io::ExportOptions::default(); - if let Some(q) = settings.jpeg_quality { - opts.encode.jpeg_quality = q; - } - opts.encode.webp_lossless = settings.webp_lossless; - if let Some(q) = settings.webp_quality { - opts.encode.webp_quality = q; - } - opts.tiff_layers = settings.tiff_layers; - opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - opts.tga_bits = settings.tga_bits; - opts.image_previews = settings.image_previews; - opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; - photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { // The browser's file picker hands over the files' contents, not paths. File › Open diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 8632fd2a55..66acb9a42f 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -233,9 +233,10 @@ pub struct Candidate { /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for /// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). +/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter, +/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788). fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { - let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan); + let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl)); let ty = match a.device_type { wgpu::DeviceType::DiscreteGpu => 0, wgpu::DeviceType::IntegratedGpu => 1, @@ -635,6 +636,20 @@ mod tests { assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1)); } + /// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12). + /// GL drew a black window; WARP works. GL stays the last resort. + #[test] + fn windows_auto_prefers_warp_over_gl() { + use wgpu::{Backend as B, DeviceType as T}; + let svga_gl = cand(0x15ad, T::Other, B::Gl); + let warp = cand(0x1414, T::Cpu, B::Dx12); + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0)); + // Elsewhere GL keeps its rank. + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0)); + } + #[test] fn adapters_must_present_even_when_none_support_the_surface() { let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..36c7c2ccf5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -228,20 +228,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R } fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { - let mut opts = photocraft_io::ExportOptions::default(); - if let Some(q) = settings.jpeg_quality { - opts.encode.jpeg_quality = q; - } - opts.encode.webp_lossless = settings.webp_lossless; - if let Some(q) = settings.webp_quality { - opts.encode.webp_quality = q; - } - opts.tiff_layers = settings.tiff_layers; - opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - opts.tga_bits = settings.tga_bits; - opts.image_previews = settings.image_previews; - opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; - opts + settings.io_options() } /// The share of a save's progress bar that encoding fills; writing the file fills the rest. A @@ -907,6 +894,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,12 +915,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 75c1388f94..fc063d2fbc 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option< #[cfg(any(target_os = "linux", test))] const BTN_TOOL_PEN: u32 = 0x140; -/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line -/// is its key bitmap as hex words of the kernel's `long`, most significant first. +/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports. +#[cfg(any(target_os = "linux", test))] +const ABS_X: u32 = 0x00; +#[cfg(any(target_os = "linux", test))] +const ABS_Y: u32 = 0x01; + +/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose +/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y` +/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard +/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but +/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a +/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639). #[cfg(any(target_os = "linux", test))] pub fn has_pen(devices: &str) -> bool { - let bits = usize::BITS; - let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX); - devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| { - keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1) + devices.split("\n\n").any(|device| { + let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name)); + bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y)) }) } +/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most +/// significant first. Short or malformed bitmaps have no bits set. +#[cfg(any(target_os = "linux", test))] +fn bit_set(bitmap: &str, bit: u32) -> bool { + let bits = usize::BITS; + let word = usize::try_from(bit / bits).unwrap_or(usize::MAX); + bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1) +} + /// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland, /// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another /// X app would outlive a mouse stroke here), so nothing starts. @@ -238,6 +256,49 @@ mod tests { for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] { assert!(!has_pen(keys), "{keys:?}"); } + for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] { + assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}"); + } + } + + /// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has + /// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window + /// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are + /// the device's own, from `/proc/bus/input/devices`. + const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\ + N: Name=\"RustDesk UInput Keyboard\"\n\ + P: Phys=\n\ + S: Sysfs=/devices/virtual/input/input54\n\ + U: Uniq=\n\ + H: Handlers=sysrq rfkill kbd event256 leds \n\ + B: PROP=0\n\ + B: EV=20013\n\ + B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\ + B: MSC=10\n\ + B: LED=7\n"; + + #[test] + fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() { + assert!( + bit_set( + "ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe", + BTN_TOOL_PEN + ), + "the fixture claims the key" + ); + assert!(!has_pen(VIRTUAL_KEYBOARD)); + // Beside a real pen, the pen is still found; the keyboard's key bits never leak into the + // pen's device and the pen's axes never into the keyboard's. + let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}"); + assert!(has_pen(&both)); + let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::>().join("\n"); + assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions"); + // A pen display or pen-enabled touchscreen reports the pen with its own axes. + let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"; + assert!(has_pen(pen_display)); + // Axes without `ABS_X` or `ABS_Y` don't count. + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n")); + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n")); } #[test] diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs index 1b814746b5..e418049d07 100644 --- a/apps/photocraft/src/window_frame.rs +++ b/apps/photocraft/src/window_frame.rs @@ -81,17 +81,16 @@ mod platform { HwndPlace::None, POINT::new(), SIZE::new(), - co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); } - /// How far a parked maximize reaches past its monitor on the left, top and right: the resize + /// How far a parked maximize reaches past a monitor/work-area edge: the resize /// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not /// parked but being dragged or snapped, and is left alone. const FRAME: i32 = 32; - /// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under - /// the taskbar back to the work area plus its frame (#3144). No-op otherwise. + /// Keep a parked maximize inside all four work-area edges, including a top taskbar. fn keep_clear_of_the_taskbar(hwnd: &HWND) { if !hwnd.IsZoomed() { return; @@ -100,34 +99,58 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else { + let Some((left, top, right, bottom)) = + clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) + else { return; }; + let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else { + return; + }; + // Moving the outer frame while winit pins the client rect to the work area must + // not copy the old frame's pixels into the custom title bar. let _ = hwnd.SetWindowPos( HwndPlace::None, - POINT::new(), - SIZE { cx: r.right - r.left, cy: height }, - co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + POINT { x: left, y: top }, + SIZE { cx: width, cy: height }, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); + let _ = hwnd.InvalidateRect(None, false); } - /// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it - /// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at - /// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the - /// monitor less the taskbar) in the same coordinates. The parked check is against the full - /// monitor, because that is where Windows puts a maximize; the trim is against the work area. - fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option { + /// Return the corrected outer rect in desktop coordinates, preserving the resize border. + /// Each edge must be parked just outside either the monitor or work-area edge. Accepting + /// both makes the guard idempotent and handles Windows already correcting some edges. + /// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed. + fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> { let (l, t, r, b) = rect; - let (ml, mt, mr, _) = monitor; - let (_, _, _, wb) = work; - let side = ml - l; - if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) { + let (ml, mt, mr, mb) = monitor; + let (wl, wt, wr, wb) = work; + if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb { return None; } - if b <= wb + side { + // Infer the frame from an edge without a taskbar. On an obstructed edge, the + // monitor and work-area offsets can both look like valid borders (e.g. auto-hide). + let border = [ + (ml == wl).then(|| ml.checked_sub(l)).flatten(), + (mt == wt).then(|| mt.checked_sub(t)).flatten(), + (mr == wr).then(|| r.checked_sub(mr)).flatten(), + (mb == wb).then(|| b.checked_sub(mb)).flatten(), + ] + .into_iter() + .flatten() + .filter(|n| (0..=FRAME).contains(n)) + .min()?; + let parked = |monitor: Option, work: Option| monitor == Some(border) || work == Some(border); + if !parked(ml.checked_sub(l), wl.checked_sub(l)) + || !parked(mt.checked_sub(t), wt.checked_sub(t)) + || !parked(r.checked_sub(mr), r.checked_sub(wr)) + || !parked(b.checked_sub(mb), b.checked_sub(wb)) + { return None; } - Some(wb + side - t) + let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?); + (corrected != rect).then_some(corrected) } /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes @@ -139,7 +162,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_height}; + use super::{borderless, clamped_rect}; use winsafe::co; #[test] @@ -156,34 +179,47 @@ mod platform { } #[test] - fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() { - // 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole - // monitor, taskbar included (#3144). Trimmed so the rect ends at the work area - // plus the frame the window was parked with. + fn a_maximize_respects_taskbars_on_every_edge() { let monitor = (0, 0, 1920, 1080); - let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7))); + let rect = (-8, -8, 1928, 1088); + for (work, expected) in [ + ((0, 0, 1920, 1046), (-8, -8, 1928, 1054)), + ((0, 48, 1920, 1080), (-8, 40, 1928, 1088)), + ((0, 2, 1920, 1080), (-8, -6, 1928, 1088)), + ((0, 0, 1920, 1078), (-8, -8, 1928, 1086)), + ((48, 0, 1920, 1080), (40, -8, 1928, 1088)), + ((0, 0, 1872, 1080), (-8, -8, 1880, 1088)), + ] { + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected"); + } } #[test] - fn a_maximize_already_clear_of_the_taskbar_is_left_alone() { - // What Windows sizes a framed maximize to: the work area plus the frame. - assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None); + fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() { + let monitor = (-1920, -1080, 0, 0); + let work = (-1920, -1032, 0, 0); + for border in [0, 8, 16, 32] { + let rect = (-1920 - border, -1080 - border, border, border); + let expected = (-1920 - border, -1032 - border, border, border); + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None); + } } #[test] - fn a_maximize_beside_a_side_taskbar_is_left_alone() { - // Taskbar on the left: the work area starts further right, but the monitor's bottom - // is still clear, so the rect reaches nothing. - assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None); - } - - #[test] - fn a_maximize_being_dragged_away_is_left_alone() { + fn a_maximize_without_a_reserved_taskbar_is_left_alone() { let monitor = (0, 0, 1920, 1080); - let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved"); - assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized"); + assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None); + } + + #[test] + fn a_maximize_being_dragged_or_snapped_is_left_alone() { + let monitor = (0, 0, 1920, 1080); + let work = (0, 48, 1920, 1080); + assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved"); + assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); + assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); } } } diff --git a/assets/icons/quick-selection.svg b/assets/icons/quick-selection.svg new file mode 100644 index 0000000000..454b38bae6 --- /dev/null +++ b/assets/icons/quick-selection.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..8694c72698 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,7 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.sidney] +real_name = "Sidney Markowitz" +sync_github_name = true diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4f0f9afe52..3e4e066156 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -132,8 +132,10 @@ test fixtures, not an exporter. ## Safety limits -Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by -default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the +Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import +and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels +are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB +zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs index 31e2bf973a..63858fe717 100644 --- a/crates/affinity/fuzz/fuzz_targets/preview.rs +++ b/crates/affinity/fuzz/fuzz_targets/preview.rs @@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = photocraft_affinity::container::header(data); let _ = photocraft_affinity::preview(data); - let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs index fd6128a66a..1f3895279f 100644 --- a/crates/affinity/fuzz/fuzz_targets/stream.rs +++ b/crates/affinity/fuzz/fuzz_targets/stream.rs @@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|stream: &[u8]| { let _ = photocraft_affinity::stream::parse(stream); let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None); - let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index b1a60ed430..880b7236d1 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -26,11 +26,16 @@ pub struct Limits { pub max_entry: usize, /// Total bytes extracted over the whole import. pub max_total: usize, + /// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the + /// temporary planes the layer being read decodes through. The entry limits bound what is + /// extracted, not what the layers materialized out of it cost (#4960). + pub max_pixel_bytes: usize, } impl Default for Limits { fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } + // The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`). + Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 } } } diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 07d0adf788..7fba5a4597 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - Reader::new(&s, &mut archive).document() + Reader::new(&s, &mut archive, limits).document() } pub(crate) struct Reader<'s, 'a, 'b> { @@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so + /// far hold). + pixels_left: usize, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { + Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + } + + /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's + /// output, which stays with the document; `planes` is the working set it decodes through and + /// releases. Every layer read so far is still live while this one decodes, so the peak is all + /// three together. + pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> { + let peak = rgba.saturating_add(planes); + if peak > self.pixels_left { + return Err(Error::Limit("decoded pixel layers exceed the pixel budget")); + } + self.pixels_left -= rgba; + Ok(()) } pub(crate) fn warn(&mut self, what: impl Into) { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 8cdf9b833a..bb1ecc112f 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi return Ok(None); } }; + // A layer is materialized from its declared size, whatever the tiles hold: admit the output + // and the planes it decodes through before either is allocated (#4960). + let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?; + let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?; + r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?; let mut planes = Vec::with_capacity(channels); let mut cache = HashMap::new(); for c in 1..=channels { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index b28e30f3d6..59ae99044a 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -251,9 +251,9 @@ mod archive { broken[90] ^= 0x55; let mut a = Archive::open(&broken, Limits::default()).unwrap(); assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap(); a.read("doc.dat").unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } @@ -459,3 +459,82 @@ mod shapes { assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } } + +/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive +/// extracts; these cover what the decoded layers and their working planes cost, which is declared +/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all. +mod pixels { + use crate::Error; + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::synth::{self, F, Method, tag}; + + /// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles. + fn layer(id: u32, w: i32, h: i32) -> F { + F::Def( + id, + vec![tag(b"Rstr")], + vec![ + (tag(b"Desc"), F::Str(format!("Layer {id}"))), + (tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])), + ], + ) + } + + /// A document whose one page holds a pixel layer per `sizes` entry. + fn document(sizes: &[(i32, i32)]) -> Vec { + let children: Vec = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect(); + let data = synth::stream(&[( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))], + )]), + )], + ), + )]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) + } + + fn imported_layers(d: &Document) -> usize { + d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count() + } + + /// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two. + const ONE_LAYER: usize = 40 << 10; + + #[test] + fn a_pixel_layer_within_the_budget_imports() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap(); + assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings); + } + + #[test] + fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + // The document is a few hundred bytes: the sizes are in the headers, not the entries. + let bytes = document(&[(64, 64), (64, 64)]); + assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len()); + assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized"); + // Two layers fit once the budget covers their pixels together. + let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() }; + assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2); + } + + #[test] + fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() { + // 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget. + let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() }; + assert!( + matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))), + "16 MiB of declared pixels materialized under a 1 MiB budget" + ); + } +} diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 7d1dd0fe6e..050d08c322 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -281,7 +281,7 @@ fn warp( // that matters is the one at the source point landing in the clip's centre. let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) { Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0), - None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0), + None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0), }; let scale = h.local_scale(cx, cy); let reduced; @@ -366,10 +366,10 @@ fn warp( return Ok(None); } let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect(); - let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; + let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); + let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); // Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint. let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0); let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) }; @@ -392,6 +392,15 @@ fn warp( px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64 } }; + // Samples past the source rect repeat its edge, so a stretched edge row or column keeps its + // colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage + // instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels. + let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c); + let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0); + let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0)); + let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 }; + let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs()); + let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs()); let w = t.width() as usize; let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; @@ -424,7 +433,7 @@ fn warp( for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix + dx, iy + dy, c) * wx * wy; + *s += clamped(ix + dx, iy + dy, c) * wx * wy; } } } @@ -436,12 +445,21 @@ fn warp( for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; + *s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; } } } } } + if interp != Interp::Nearest { + // How much of this pixel the warped source rect covers, per axis. + let (uc, vc) = (u + 0.5, v + 0.5); + let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0); + let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0); + for s in acc.iter_mut().take(n) { + *s *= cu * cv; + } + } let al = acc[a].clamp(0.0, 1.0); if al <= 0.0 { continue; @@ -539,6 +557,24 @@ mod tests { Surface::new(PixelFormat::RGBA8) } + /// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in + /// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased. + #[test] + fn stretched_single_column_stays_opaque() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap(); + let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap(); + assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}"); + for x in [0, 1, 25, 50, 75, 99] { + for y in 0..4 { + assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3)); + } + } + } + } + /// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left /// half is grey 64 and right half grey 192: the row across the edge, columns 26..36. #[test] diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/warp_clipped.rs new file mode 100644 index 0000000000..ac6497f13f --- /dev/null +++ b/crates/algo/tests/warp_clipped.rs @@ -0,0 +1,59 @@ +use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +#[test] +fn clipped_warp_matches_the_existing_kernel_at_every_depth() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]); + source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]); + let clip = Rect::new(10, 10, 52, 52); + for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] { + let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap(); + let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap(); + assert_eq!(full.read_region(clip), bounded.read_region(clip)); + assert!(clip.contains_rect(&bounded.content_bounds())); + assert!(bounded.tiles().count() <= clip.tiles().count()); + } + } + } +} + +#[test] +fn huge_scaled_warp_allocates_only_the_destination_clip() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]); + let clip = Rect::new(0, 0, 64, 64); + let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); + assert_eq!(output.pixel(32, 32), source.pixel(10, 10)); + assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none()); +} + +#[test] +fn inverse_footprint_handles_extreme_finite_affines() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + // A strongly skewed inverse maps a small visible tile beyond i32 limits. + let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let clip = Rect::new(0, 0, 32, 32); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); +} + +#[test] +fn source_center_does_not_add_extreme_coordinates_in_i32() { + let mut source = Surface::new(PixelFormat::RGBA8); + let origin = 1_500_000_000; + let bounds = Rect::new(origin, origin, origin + 4, origin + 4); + source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]); + let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap(); + assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2)); +} diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 507010b9c8..dc01f0f2b4 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -91,17 +91,9 @@ impl Headless { let all = self.command_list(); Ok(match str_of(&p, "filter").map(str::to_lowercase) { None => all, - Some(n) => Value::Array( - all.as_array() - .into_iter() - .flatten() - .filter(|c| { - let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")); - hay.to_lowercase().contains(&n) - }) - .cloned() - .collect(), - ), + Some(n) => { + Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect()) + } }) } "session.list" => Ok(self.session_list()), @@ -365,7 +357,7 @@ mod tests { /// Pins the reply shape documented in docs/control-protocol.md › Command list replies /// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with - /// exactly these keys, from `engine.commands` (filtered or not) and `command.list`. + /// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`. #[test] fn command_list_replies_are_documented_array_of_objects() { let h = session(); @@ -377,8 +369,10 @@ mod tests { let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}")); assert!(!items.is_empty(), "{name}"); for c in items { - let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect(); + // `keywords` is optional: only commands with other search names carry it (#5060). + let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect(); keys.sort_unstable(); + assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}"); assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}"); assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}"); assert!(c["needsDocument"].is_boolean(), "{c}"); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b31507b005..c5a0c3b180 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -205,7 +205,7 @@ pub struct SelectParams { #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListParams { - /// Only commands whose id or label contains this text (case-insensitive). + /// Only commands whose id, label or keywords contain this text (case-insensitive). #[serde(default)] pub filter: Option, /// Only commands that can run right now. @@ -320,7 +320,8 @@ pub struct UiSetParams { /// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never), /// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled, /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), - /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, + /// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware, + /// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the @@ -821,9 +822,8 @@ impl PhotocraftMcp { .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) .into_iter() .filter(|c| { - let hay = - format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase(); - needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) + needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) + && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) }) .collect(); Ok(ok_json(&Value::Array(items))) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index cde3a3a702..23b64ae30e 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -363,7 +363,7 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } @@ -758,6 +758,7 @@ mod tests { fn automation_rejects_ambient_path_commands_and_preferences() { for (id, params) in [ ("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})), + ("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})), ("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})), ("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})), ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), @@ -772,6 +773,7 @@ mod tests { } for (id, params) in [ ("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})), + ("brush.presets.importImage", serde_json::json!({"data": "QUJD"})), ("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})), ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index ca34d9f915..7386f3d58c 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -167,6 +167,9 @@ async fn command_list_filters() { let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await); assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all); assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string())); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await); + assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}"); let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await); assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new")); assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document"); diff --git a/crates/cms/README.md b/crates/cms/README.md index 6ae6a76cfb..c48d5366fd 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -1,6 +1,6 @@ # photocraft-cms -Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable), +Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable), no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper. @@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou | `rec2020` | Rec. 2020 primaries, Rec. 709 OETF | | `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) | | `lab-d50` | Lab identity (v4 encoding) | -| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | +| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | ### The CMYK profile Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry -redistribution conditions or unclear licences, so Photocraft generates its own +redistribution conditions or unclear licences, so PhotoCraft generates its own (`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY, 17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink diff --git a/crates/cms/profiles/photocraft-coated-cmyk.icc b/crates/cms/profiles/photocraft-coated-cmyk.icc index 0d2d1da16d..b553fe9cec 100644 Binary files a/crates/cms/profiles/photocraft-coated-cmyk.icc and b/crates/cms/profiles/photocraft-coated-cmyk.icc differ diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 9d42fef3f6..8f3dc88a15 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -26,6 +26,8 @@ pub enum Builtin { ProPhotoCompat, /// sRGB primaries with a linear transfer function. LinearSrgb, + /// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders. + AcesCg, /// Rec. 2020 primaries, D65, Rec. 709 transfer function. Rec2020, /// Gray, gamma 2.2. @@ -43,12 +45,13 @@ pub enum Builtin { } impl Builtin { - pub const ALL: [Builtin; 11] = [ + pub const ALL: [Builtin; 12] = [ Builtin::Srgb, Builtin::DisplayP3, Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat, Builtin::LinearSrgb, + Builtin::AcesCg, Builtin::Rec2020, Builtin::GrayGamma22, Builtin::SGray, @@ -65,6 +68,7 @@ impl Builtin { Builtin::AdobeRgbCompat => "adobe-rgb-compat", Builtin::ProPhotoCompat => "prophoto-compat", Builtin::LinearSrgb => "linear-srgb", + Builtin::AcesCg => "aces-cg", Builtin::Rec2020 => "rec2020", Builtin::GrayGamma22 => "gray-gamma-2.2", Builtin::SGray => "sgray", @@ -79,14 +83,15 @@ impl Builtin { match self { Builtin::Srgb => "sRGB IEC61966-2.1", Builtin::DisplayP3 => "Display P3", - Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)", - Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)", - Builtin::LinearSrgb => "Linear sRGB (Photocraft)", - Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", - Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", - Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", - Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)", - Builtin::LabD50 => "Lab D50 identity (Photocraft)", + Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)", + Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)", + Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)", + Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)", + Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)", + Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)", + Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)", + Builtin::LabD50 => "Lab D50 identity (PhotoCraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, } } @@ -104,6 +109,7 @@ impl Builtin { "gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22), "sgray" | "graysrgb" => Some(Builtin::SGray), "dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat), + "aces-cg" | "acescg" => Some(Builtin::AcesCg), "lab" | "labd50" | "cielab" => Some(Builtin::LabD50), "cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk), _ => None, @@ -113,7 +119,7 @@ impl Builtin { /// The profile (built once per process). pub fn profile(self) -> &'static Profile { - static CELLS: [OnceLock; 11] = [const { OnceLock::new() }; 11]; + static CELLS: [OnceLock; 12] = [const { OnceLock::new() }; 12]; // `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`), // so the discriminant is the cell index. CELLS[self as usize].get_or_init(|| build(self)) @@ -140,7 +146,7 @@ pub fn srgb() -> &'static Profile { /// only assigned to such files, never offered as a working space. pub fn linear_gray() -> &'static Profile { static PROFILE: OnceLock = OnceLock::new(); - PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) + PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0))) } /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). @@ -256,12 +262,13 @@ fn lab() -> Profile { pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc"); fn build(b: Builtin) -> Profile { - let (srgb_p, p3, adobe, romm, bt2020) = ( + let (srgb_p, p3, adobe, romm, bt2020, ap1) = ( [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], [[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], [[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], ); match b { Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()), @@ -269,6 +276,9 @@ fn build(b: Builtin) -> Profile { Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)), Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)), Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)), + // ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60, + // xy 0.32168/0.33767, the ACES 2065-1 neutral). + Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)), Builtin::Rec2020 => { // Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. let a = 1.099; diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99f5710e5d..cdd7d5273a 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -429,10 +429,18 @@ impl<'a> Reader<'a> { Ok(self.u32(off)? as i32 as f64 / 65536.0) } fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> { - Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?]) + let r = Reader { b: self.slice(off, 12)? }; + Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?]) } } +/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not +/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32): +/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976). +fn extent(base: usize, n: usize, size: usize) -> Result { + n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated) +} + fn sig(s: &[u8; 4]) -> u32 { u32::from_be_bytes(*s) } @@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { match r.slice(0, 4)? { b"curv" => { let n = r.u32(8)? as usize; + // Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976). + if n > 1 << 20 { + return Err(CmsError::Invalid("curve too long".into())); + } let len = 12 + n * 2; match n { 0 => Ok((Curve::Identity, len)), 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { - if n > 1 << 20 { - return Err(CmsError::Invalid("curve too long".into())); - } let s = r.slice(12, n * 2)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) @@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { return Err(CmsError::Invalid("bad lutAB dimensions".into())); } let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize); - let matrix = |off: usize| -> Result { - let mut m = vec![0.0; 9]; - for (k, v) in m.iter_mut().enumerate() { - *v = r.s15f16(off + k * 4)?; - } - let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?]; - Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) - }; - let clut = |off: usize, cin: usize, cout: usize| -> Result { - let mut grid = Vec::with_capacity(cin); - for k in 0..cin { - let g = r.u8(off + k)? as usize; - if g < 1 { - return Err(CmsError::Invalid("zero grid points".into())); - } - grid.push(g); - } - let prec = r.u8(off + 16)? as usize; - let nodes: u128 = grid.iter().map(|g| *g as u128).product(); - if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { - return Err(CmsError::Invalid("CLUT too large".into())); - } - let total = nodes as usize * cout; - let data: Vec = match prec { - 1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(), - 2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), - }; - Ok(Stage::Clut(Clut::new(grid, cout, data))) - }; + let matrix = |off: usize| lut_ab_matrix(&r, off); + let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout); let mut stages = Vec::new(); if ty == b"mAB " { // A curves → CLUT → M curves → matrix → B curves. @@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { } } +/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_matrix(r: &Reader, off: usize) -> Result { + // One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`. + let e = Reader { b: r.slice(off, 48)? }; + let mut m = vec![0.0; 9]; + for (k, v) in m.iter_mut().enumerate() { + *v = e.s15f16(k * 4)?; + } + let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?]; + Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) +} + +/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result { + // Grid points (16 bytes), precision and padding; the samples follow. + let head = Reader { b: r.slice(off, 20)? }; + let mut grid = Vec::with_capacity(cin); + for k in 0..cin { + let g = head.u8(k)? as usize; + if g < 1 { + return Err(CmsError::Invalid("zero grid points".into())); + } + grid.push(g); + } + let prec = head.u8(16)? as usize; + let nodes: u128 = grid.iter().map(|g| *g as u128).product(); + if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { + return Err(CmsError::Invalid("CLUT too large".into())); + } + let total = nodes as usize * cout; + let samples = extent(off, 20, 1)?; + let data: Vec = match prec { + 1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(), + 2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), + }; + Ok(Stage::Clut(Clut::new(grid, cout, data))) +} + /// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`. fn parse_text(d: &[u8]) -> String { let r = Reader { b: d }; @@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String { let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() }; let mut best: Option<(usize, usize)> = None; for k in 0..(n as usize).min(256) { - let o = 16 + k * size as usize; - let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break }; + // The record size comes from the file: `k * size` can overflow a 32-bit `usize`. + let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break }; + let rec = Reader { b: rec }; + let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break }; if best.is_none() || lang == u16::from_be_bytes(*b"en") { best = Some((off as usize, len as usize)); if lang == u16::from_be_bytes(*b"en") { @@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn be(v: u32) -> [u8; 4] { + v.to_be_bytes() + } + + /// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on + /// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow. + #[test] + fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() { + let bytes = [0u8; 64]; + let r = Reader { b: &bytes }; + for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] { + assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}"); + assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}"); + assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}"); + } + assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated)); + assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated)); + assert_eq!(extent(16, 3, 12), Ok(52)); + } + + #[test] + fn a_curve_count_is_checked_before_its_length_is_computed() { + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into()))); + // Controls: the identity and a two-point table still parse with their lengths. + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(0)); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12)); + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend([0, 0, 0xFF, 0xFF]); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16)); + } + + #[test] + fn lut_ab_elements_at_hostile_offsets_are_rejected() { + // A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT + // offsets in turn point at the end of a 32-bit file. + let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| { + let mut d = b"mAB \0\0\0\0".to_vec(); + d.extend([3, 3, 0, 0]); + for v in [b, matrix, m, clut, a] { + d.extend(be(v)); + } + for _ in 0..3 { + d.extend(b"curv\0\0\0\0"); + d.extend(be(0)); + } + d + }; + assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses"); + let top = u32::MAX - 8; + assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset"); + assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset"); + assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset"); + } + + #[test] + fn multilingual_records_with_a_huge_record_size_give_the_first_record() { + let record = |lang: &[u8; 2], len: u32, off: u32| { + let mut r = lang.to_vec(); + r.extend(b"US"); + r.extend(be(len)); + r.extend(be(off)); + r + }; + // Two records of the standard 12 bytes: the English one wins. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend(be(12)); + d.extend(record(b"de", 4, 40)); + d.extend(record(b"en", 4, 44)); + d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']); + assert_eq!(parse_text(&d), "EN"); + // A record size of u32::MAX: the second record lies past any file, so the first is used. + d[12..16].copy_from_slice(&be(u32::MAX)); + assert_eq!(parse_text(&d), "DE"); + // No records at all. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + d.extend(be(u32::MAX)); + assert_eq!(parse_text(&d), ""); + } +} diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 657c829f31..96a23dbfce 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -29,7 +29,7 @@ use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; +pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; /// Parameters of the synthetic profile. #[derive(Clone, Debug)] diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 2506dd062b..a55810b4b8 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their +/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "no header-only size probe for deep data")); + } + let pick = pick_part(&meta); + let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?; + if info.color_rank() == 0 { + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); + } + Ok((info.width, info.height)) +} + /// Decode one part of a multi-part file by its index into [`info`]'s list. A file with /// deep parts is not mixed: the deep decoder composites those instead. pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result { diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 3aa769fb01..7e3f92ae67 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Result>, CodecErr Ok(decoder.get_palette().map(|palette| palette.to_vec())) } -pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { +/// The `image` decoder for `f` with our limits, its header read and its size checked. +fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result { let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); let mut il = image::Limits::default(); @@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result { + let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?; @@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result<(u32, u32, Option>), CodecError> { + if f == Format::Avif { + return Err(CodecError::unsupported(f, "no header-only size probe")); + } + let mut dec = open(f, bytes, limits)?; + let (w, h) = dec.dimensions(); + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. fn gif_more_frames(b: &[u8]) -> Option { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 36473041dd..b7b6e2a668 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } -pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { +type Decoder<'a> = image_webp::WebPDecoder>; + +/// The decoder with its chunks read, the stored size and the output layout, the size checked. +fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> { let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; dec.set_memory_limit(limits.alloc_usize()); let (w, h) = dec.dimensions(); @@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the // ratio is generous; it still stops a header-only file from asking for a gigabyte. Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?; + Ok((dec, w, h, layout)) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let (mut dec, w, h, layout) = open(bytes, limits)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; @@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the +/// same header reads and size checks, without decoding the bitstream. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option>), CodecError> { + let (mut dec, w, h, _) = open(bytes, limits)?; + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index d5654d27a0..08153dfbab 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -195,7 +195,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if lossy_depth { w.push(W::DepthReduced { from: ss, to: p.sample }); } - if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() { + // FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too. + if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() { w.push(W::RangeClipped); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..3831cf654f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -102,6 +102,27 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res finish_decode(img, o, opts) } +/// The upright pixel size [`decode`] would return, read from the file's header without decoding +/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same +/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an +/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and +/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size. +pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let format = detect(bytes).ok_or(CodecError::UnknownFormat)?; + if !caps(format).read { + return Err(CodecError::unsupported(format, "decoding is not available for this format")); + } + let l = &Limits::default(); + let (w, h, exif) = match format { + Format::WebP => webp::probe(bytes, l)?, + Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?, + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?, + _ => return Err(CodecError::unsupported(format, "no header-only size probe")), + }; + let o = exif.as_deref().map_or(1, exif_orientation); + Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) +} + /// Returns the embedded colour table for an indexed BMP, if it has one. pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/fidelity.rs index 32d5e21bf0..6a7002fc2d 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/fidelity.rs @@ -115,6 +115,12 @@ fn in_range_float_not_clipped() { assert!(!w.contains(&W::RangeClipped)); } #[test] +fn hdr_float_to_fits_clips() { + let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap(); + assert!(warns(&img, Format::Fits).contains(&W::RangeClipped)); + assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped)); +} +#[test] fn float_to_exr_and_tiff_is_clean() { for f in [Format::OpenExr, Format::Tiff, Format::Pnm] { assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}"); diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index 9808330790..c1f176b87c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -236,6 +236,7 @@ fn webp_opens_upright() { // WebP has no gray mode: it comes back as RGB. let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8); assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}"); + assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}"); } } @@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() { assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]); } } + +/// `probe_size` reads the size `decode` returns from the header alone, for every format it +/// covers, and a file cut short inside its header is refused rather than given a size. +#[test] +fn probe_size_matches_decode() { + let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0); + let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr]; + assert!(probed.iter().all(|f| rw_formats().contains(f))); + for format in rw_formats() { + let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); + let decoded = decode(&bytes).unwrap().dimensions(); + match probe_size(&bytes) { + Ok(size) => assert_eq!(size, decoded, "{format:?}"), + Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"), + } + if probed.contains(&format) { + assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header"); + } + } + assert!(probe_size(&[]).is_err()); +} diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 22a8cf063a..f91e7779d1 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -79,37 +79,29 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option) { match adj { Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]), - Adjustment::Threshold { level } => { - // Compared on the 8-bit grid like Photoshop (avoids float ties). - let t = (level * 255.0).round(); - map_rgb(buf, |c| { - let v = if (rgb_to_gray(c) * 255.0).round() >= t { 1.0 } else { 0.0 }; - [v; 3] - }) - } - Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))), + Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]), + Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize_in(v, *levels, depth))), Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { // Legacy: contrast scales around 128/255, with brightness added before it when // contrast is raised and after it when contrast is lowered. Measured on Photoshop // 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while // the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first; // contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130). - let f = legacy_brightness_contrast(*brightness, *contrast); - map_rgb(buf, |px| px.map(&f)) + if depth == Some(SampleType::F32) { + let f = legacy_brightness_contrast_hdr(*brightness, *contrast); + map_rgb(buf, |px| px.map(&f)); + } else { + let f = legacy_brightness_contrast(*brightness, *contrast); + map_rgb(buf, |px| px.map(&f)) + } } Adjustment::BrightnessContrast { brightness, contrast, .. } => { - // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth - // (a 0..255 ramp pushed through the real app; see log/devlog.md). Unlike the legacy - // linear scale, both are smooth curves that pin pure black and white: - // • Brightness: a line of slope s = 1.375^(b/50) from the origin that rolls off to - // (1,1) via a `v^P` white-anchor term (P grows as |b| grows). - // • Contrast: a symmetric cubic-Hermite S-curve pivoting at 0.5, with endpoint slope - // 1 - c/128 and centre slope 1 + c/128 (near-exact: ≤0.5/255 vs Photoshop). - // Applied brightness-then-contrast. Exact at the sliders' zero and extremes still drift - // on the brightness side (the real curve is a spline); contrast matches closely. - let b = *brightness; - let c = *contrast; - map_rgb(buf, |px| px.map(|v| modern_contrast(modern_brightness(v, b), c).clamp(0.0, 1.0))) + // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth. + // Unlike the legacy linear scale, both are smooth curves that pin pure black and white + // (see `modern_brightness` and `modern_contrast`), applied brightness then contrast + // through one lookup table. + let table = modern_lut(*brightness, *contrast); + map_rgb(buf, |px| px.map(|v| lut(&table, v))) } Adjustment::Exposure { exposure, offset, gamma } => { // In linear light: (lin·2^exposure + offset)^(1/gamma), then back @@ -175,6 +167,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth let hi = if depth == Some(SampleType::F32) { f32::INFINITY } else { 1.0 }; map_rgb(buf, |c| vibrance_px(c, v, s, transfer, hi)) } + Adjustment::ChannelMixer { matrix, monochrome } if depth == Some(SampleType::F32) => { + // 32-bit: each source percentage is cut to 1/1024 (toward zero), the constant counts + // 255/512 of its value, and nothing clips (photoshop corpus rgb32 channel-mixer.psd: + // pure red, green, blue, black and white all exact). + let cut = |v: f32| (v * 1024.0).trunc() / 1024.0; + let m = matrix.map(|row| [cut(row[0]), cut(row[1]), cut(row[2]), row[3] * 255.0 / 512.0]); + map_rgb(buf, |c| { + let mix = |row: &[f32; 4]| row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]; + if *monochrome { [mix(&m[0]); 3] } else { [mix(&m[0]), mix(&m[1]), mix(&m[2])] } + }) + } Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| { let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0); if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] } @@ -184,27 +187,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth let m = photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc); let set_lum = *preserve_luminosity && !linear_doc; map_rgb(buf, |c| { - let f = mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)); + // 32-bit samples are linear already, and may go negative. + let f = if linear_doc { mat3_mul(&m, c) } else { mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)) }; let f = if set_lum { photocraft_color::blend::set_lum(f, photocraft_color::blend::lum(c)) } else { f }; - f.map(|v| v.clamp(0.0, 1.0)) + // 32-bit results aren't clipped (photoshop corpus rgb32 photo-filter.psd: white + // filters to 1.19 red). + if linear_doc { f } else { f.map(|v| v.clamp(0.0, 1.0)) } + }) + } + Adjustment::BlackWhite { weights, tint } => { + let hdr = depth == Some(SampleType::F32); + map_rgb(buf, |c| { + let g = black_white_gray(c, weights); + match tint { + // Photoshop tints like the Color blend mode: the tint's hue and saturation at + // the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In + // 32-bit documents nothing clips at 1, as with the blend mode there (photoshop + // corpus rgb32 black-white-tint.psd: white tints to 1.05, 1.00, 0.87). + Some(t) if hdr => photocraft_color::blend::blend_rgb_within(photocraft_color::blend::BlendMode::Color, [g; 3], *t, f32::MAX), + Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)), + None => [g; 3], + } }) } - Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| { - let g = black_white_gray(c, weights); - match tint { - // Photoshop tints like the Color blend mode: the tint's hue and saturation at the - // grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. - Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)), - None => [g; 3], - } - }), Adjustment::GradientMap { stops, reverse, dither } => { let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0); for (i, p) in buf.px.iter_mut().enumerate() { if p[3] <= 0.0 { continue; } - let mut t = rgb_to_gray([p[0], p[1], p[2]]); + // Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop + // corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with + // 0.299, 0.587, 0.114). + let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]); if *reverse { t = 1.0 - t; } @@ -239,13 +254,55 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } } -/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]). -fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { +/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at +/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel +/// exact, negative and over-white values included; the 8/16-bit pivot, 128, is 0.43 of a level +/// off there). +fn legacy_brightness_contrast_hdr(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { + const PIVOT: f32 = 127.0 / 255.0; let b = brightness / 255.0; let c = contrast.clamp(-100.0, 99.0); let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; - move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0) + move |v: f32| (v + pre - PIVOT) * k + PIVOT + post +} + +/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]). +fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { + legacy_brightness_contrast_at(brightness, contrast, LEGACY_PIVOT) +} + +/// [`legacy_brightness_contrast`] with contrast pivoting at `pivot`. +fn legacy_brightness_contrast_at(brightness: f32, contrast: f32, pivot: f32) -> impl Fn(f32) -> f32 { + let b = brightness / 255.0; + let c = contrast.clamp(-100.0, 99.0); + let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; + let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; + move |v: f32| ((v + pre - pivot) * k + pivot + post).clamp(0.0, 1.0) +} + +/// Whether Threshold at `level` turns colour `c` white in a document of `depth` (`None`: unknown, +/// treated as 8-bit). Photoshop splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend +/// modes' `lum`), not the Rec. 601 grey, of the document's whole sample values: +/// - 8 bits: white when that luminosity, rounded to a level with halves down, reaches the level; +/// - 16 bits: white when it reaches the level converted to 16 bits (128 → 16448); +/// - 32 bits: white when it reaches the level. +/// +/// Exact on every pixel of psd-tools threshold_rgb.psd, threshold_16bits_rgb.psd and +/// threshold_16bits_grayscale.psd and of the photoshop corpus's rgb16 threshold-128.psd, where the +/// Rec. 601 grey split 28 and 100 pixels of the first two the other way. The sums of +/// `30 R + 59 G + 11 B` over whole samples are exact in f32 (at most 3 276 800). +pub fn threshold_white(c: [f32; 3], level: f32, depth: Option) -> bool { + let t = (level * 255.0).round(); + let sum = |q: f32| { + let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * q).round()); + 30.0 * r + 59.0 * g + 11.0 * b + }; + match depth { + None | Some(SampleType::U8) => sum(255.0) > 100.0 * t - 50.0, + Some(SampleType::U16) => sum(32768.0) >= 100.0 * (t * 32768.0 / 255.0).round(), + Some(SampleType::F32) => (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) * 255.0 >= t, + } } /// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab @@ -256,11 +313,25 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O apply_lab(adj, buf, depth); } else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) { use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; - map_rgb(buf, |c| { - let mut ink = rgb_to_cmyk(c); - stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c)); - cmyk_to_rgb(ink) - }); + if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj { + let table = ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + map_rgb(buf, |c| cmyk_to_rgb(rgb_to_cmyk(c).map(|v| ink_through(&table, v)))); + } else if cmy_adjustment(adj) { + let mut inks: Vec<[f32; 4]> = buf.px.iter().map(|p| rgb_to_cmyk([p[0], p[1], p[2]])).collect(); + apply_cmy(adj, &mut inks); + for (p, ink) in buf.px.iter_mut().zip(&inks) { + if p[3] > 0.0 { + let [r, g, b] = cmyk_to_rgb(*ink); + (p[0], p[1], p[2]) = (r, g, b); + } + } + } else { + map_rgb(buf, |c| { + let mut ink = rgb_to_cmyk(c); + stored_channels(adj, mode, depth, &mut ink); + cmyk_to_rgb(ink) + }); + } } else { apply_depth(adj, buf, transfer, depth); } @@ -282,13 +353,55 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool { } /// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert -/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or -/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128 -/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour -/// separated through the document's CMYK profile, so neutrals carry the black that profile -/// generates rather than the file's. +/// and Posterize (each ink, black included, so paper inverts to 400% ink), Threshold (paper or +/// 100% black ink alone) and Brightness/Contrast (each ink's stored value, 1 - ink, through the +/// tone curve; see [`ink_through`]). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4, +/// threshold-128 and brightness-contrast(-legacy) follow these rules on the files' own inks on +/// every pixel; here the inks are the screen colour separated through the document's CMYK +/// profile, so neutrals carry the black that profile generates rather than the file's. pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool { - stored_channel_adjustment(adj) + stored_channel_adjustment(adj) || cmy_adjustment(adj) || matches!(adj, Adjustment::BrightnessContrast { .. }) +} + +/// Whether a CMYK document evaluates `adj` on C, M and Y as the complement of an RGB colour, +/// black kept ([`apply_cmy`]): Hue/Saturation (not Colorize, which Photoshop separates again, +/// black and all) and Color Balance (photoshop corpus cmyk16 hue-saturation.psd follows this on +/// every pixel within half a level, cmyk8 within two; color-balance within our RGB Color +/// Balance's own error). +pub fn cmy_adjustment(adj: &Adjustment) -> bool { + matches!(adj, Adjustment::HueSaturation { colorize: false, .. } | Adjustment::ColorBalance { .. }) +} + +/// A [`cmy_adjustment`] on straight inks (C, M, Y, K; 0 none): the RGB adjustment on +/// `(1 - c, 1 - m, 1 - y)`, black left alone. +pub fn apply_cmy(adj: &Adjustment, inks: &mut [[f32; 4]]) { + let width = i32::try_from(inks.len()).unwrap_or(i32::MAX); + let mut buf = Buffer { rect: crate::Rect::new(0, 0, width, 1), px: inks.iter().map(|i| [1.0 - i[0], 1.0 - i[1], 1.0 - i[2], 1.0]).collect() }; + apply_depth(adj, &mut buf, Transfer::Srgb, None); + for (ink, p) in inks.iter_mut().zip(&buf.px) { + for c in 0..3 { + ink[c] = (1.0 - p[c]).clamp(0.0, 1.0); + } + } +} + +/// Brightness/Contrast as the lookup table (4096 samples over 0..1) a CMYK ink's stored value, +/// `1 - ink`, goes through ([`ink_through`]): [`modern_lut`], or the legacy linear curve with its +/// contrast pivoting at 128 in ink, which is 127 of 255 stored (photoshop corpus cmyk16 +/// brightness-contrast-legacy.psd sits 0.43 of a level off around 128 stored, as a one-level +/// pivot shift times contrast +30's slope predicts). +pub fn ink_brightness_contrast_lut(brightness: f32, contrast: f32, legacy: bool) -> Vec { + if !legacy { + return modern_lut(brightness, contrast); + } + let curve = legacy_brightness_contrast_at(brightness, contrast, 1.0 - LEGACY_PIVOT); + (0..LUT_SIZE).map(|k| curve(k as f32 / (LUT_SIZE - 1) as f32)).collect() +} + +/// One CMYK ink (0: none) through a channel tone curve the way Photoshop applies one in CMYK: to +/// the stored value, `1 - ink`, so raising brightness takes ink away. +pub fn ink_through(table: &[f32], ink: f32) -> f32 { + 1.0 - lut(table, 1.0 - ink) } /// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold. @@ -298,10 +411,10 @@ pub fn stored_channel_adjustment(adj: &Adjustment) -> bool { /// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of /// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`] -/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold -/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and -/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral). -pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option, ch: &mut [f32], gray: f32) { +/// gives them, or the C, M, Y, K inks (0: none). Lab Invert turns 8-bit bytes `v` into `255 - v` +/// (a becomes -1 - a) and mirrors a and b around neutral at 16 bits (photoshop corpus lab16 +/// invert.psd: neutral stays neutral). CMYK Threshold compares [`ink_luma`]. +pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option, ch: &mut [f32]) { let lab = mode == ColorMode::Lab; match adj { Adjustment::Invert => { @@ -310,7 +423,10 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0); } } - Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)), + // CMYK posterizes the stored values (1 - ink), which its uneven levels make differ from + // posterizing the ink: psd-tools posterize_cmyk.psd (3 to 21 levels) is exact so. + Adjustment::Posterize { levels } if mode == ColorMode::Cmyk => ch.iter_mut().for_each(|v| *v = 1.0 - posterize_in(1.0 - *v, *levels, depth)), + Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize_in(*v, *levels, depth)), Adjustment::Threshold { level } => { // On the 8-bit grid, like the RGB threshold. let t = (level * 255.0).round(); @@ -318,7 +434,9 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option= t); [if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0] } else { - [0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }] + let ink = |i: usize| ch.get(i).copied().unwrap_or(0.0); + let black = (ink_luma([ink(0), ink(1), ink(2), ink(3)]) * 255.0).round() < t; + [0.0, 0.0, 0.0, if black { 1.0 } else { 0.0 }] }; ch.iter_mut().zip(out).for_each(|(v, o)| *v = o); } @@ -326,6 +444,14 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option f32 { + let paper = 1.0 - ink[3].clamp(0.0, 1.0); + rgb_to_gray([0, 1, 2].map(|i| (1.0 - ink[i].clamp(0.0, 1.0)) * paper)) +} + /// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255, /// (b+128)/255. fn lab_norm(c: [f32; 3]) -> [f32; 3] { @@ -338,6 +464,49 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] { lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) } +/// Neutral a or b as Lab surfaces store it ((0 + 128) / 255). +pub const LAB_NEUTRAL: f32 = 128.0 / 255.0; + +/// An adjustment Photoshop evaluates on a Lab document's own channels, as an operation on one +/// pixel's stored L, a, b (normalised as Lab surfaces store them: L / 100, (a + 128) / 255, +/// (b + 128) / 255) in a document of `depth`: Invert and Posterize (each channel), Threshold +/// (split on L, neutral result), Brightness/Contrast (L) and Levels or Curves with Lab records. +/// `None` for any other adjustment. On the stored values colours outside the display gamut keep +/// theirs, where [`apply_in`] can only round-trip them through clipped display RGB. +pub fn lab_stored_op(adj: &Adjustment, depth: Option) -> Option> { + let op: LabOp<'_> = match adj { + Adjustment::Invert | Adjustment::Posterize { .. } => Box::new(move |ch: &mut [f32; 3]| stored_channels(adj, ColorMode::Lab, depth, ch)), + Adjustment::Threshold { level } => { + // Split on L on the 8-bit grid, like the display path (`apply_lab`). + let t = (level * 255.0).round(); + Box::new(move |ch: &mut [f32; 3]| { + let white = (ch[0].clamp(0.0, 1.0) * 255.0).round() >= t; + *ch = [if white { 1.0 } else { 0.0 }, LAB_NEUTRAL, LAB_NEUTRAL]; + }) + } + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + let (b, c, legacy) = (*brightness, *contrast, *legacy); + let legacy_curve = legacy_brightness_contrast(b, c); + Box::new(move |ch: &mut [f32; 3]| { + ch[0] = if legacy { legacy_curve(ch[0]) } else { modern_contrast(modern_brightness(ch[0], b), c).clamp(0.0, 1.0) }; + }) + } + Adjustment::Levels { space: ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Lab, .. } => { + let luts = tone_luts_depth(adj, depth); + Box::new(move |ch: &mut [f32; 3]| { + for (v, t) in ch.iter_mut().zip(&luts) { + *v = lut(t, *v); + } + }) + } + _ => return None, + }; + Some(op) +} + +/// One pixel's stored L, a, b through an adjustment ([`lab_stored_op`]). +pub type LabOp<'a> = Box; + /// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any /// other adjustment). fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { @@ -345,7 +514,7 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { // L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`). Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| { let mut n = lab_norm(c); - stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0); + stored_channels(adj, ColorMode::Lab, depth, &mut n); lab_denorm(n) }), Adjustment::Threshold { level } => { @@ -355,7 +524,11 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { } Adjustment::BrightnessContrast { brightness, contrast, legacy } => { let legacy_curve = legacy_brightness_contrast(*brightness, *contrast); - let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) }; + let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast)); + let curve = |v: f32| match &modern { + Some(table) => lut(table, v), + None => legacy_curve(v), + }; // The tone curve on L only. Added as the difference of two round trips (like // `tone_in_space`) so a and b, and colours a round trip moves, stay put. map_rgb(buf, |c| { @@ -591,61 +764,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f } } -/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11: -/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white). -const BALANCE_LEVELS: f32 = 0.39; - /// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the /// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue, /// −100..100). /// -/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at -/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma -/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100: -/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a. -/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is -/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised. -/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch), -/// preserving the one-slider behaviour without summing offsets when multiple sliders move. -/// Shadows and highlights go by the differences between a tone's three sliders: -/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white -/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the -/// other two channels, and two raised together darken only the third). +/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of +/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three +/// tones fold into one Levels per channel (input black point, input white point, midtone gamma), +/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights +/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423). +/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black +/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels, +/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2. +/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white +/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones. /// -/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one -/// channel: within 1 level). +/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter +/// near black without it). When the black point reaches the white point, input +/// at or above the white point is white and the rest black. pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { - let [shadows, midtones, highlights] = tones; - let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0); - let white = |v: f32, w: f32| (v / w).min(1.0); - let mut v = v.clamp(0.0, 1.0); - for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] { - let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0)); - if preserve_luminosity { - let own = a.get(ch).copied().unwrap_or(0.0); - let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x))); - v = match tone { - 0 => black(v, BALANCE_LEVELS * (hi - own)), - 1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)), - _ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)), - }; - continue; - } - for (src, slider) in sliders.iter().enumerate() { - let a = (slider / 100.0).clamp(-1.0, 1.0); - if a == 0.0 || src != ch { - continue; - } - v = match tone { - 1 => v.powf(2f32.powf(-a)), - 0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)), - 0 => v.powf(2f32.powf(-a / 2.0)), - 2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)), - _ => v.powf(2f32.powf(-a / 2.0)), - }; - } + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + let (b, w) = (black / 255.0, 1.0 - white / 255.0); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; } - v + if v <= b { + return 0.0; + } + balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0)) +} + +/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except +/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the +/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop, +/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). +fn balance_gamma(u: f32, gamma: f32) -> f32 { + let e = 1.0 / gamma.max(0.01); + let power = u.powf(e); + if gamma <= 1.0 { + return power; + } + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + if u0.is_nan() || u0 <= 0.0 || u >= u0 { + return power; + } + let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) } /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, @@ -823,20 +998,118 @@ fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 { h * 60.0 } -/// Photoshop posterize: `n` equal input bins over 0..=255, output levels -/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels). -/// Modern Brightness curve (one channel, `brightness` in [-150, 150]). A line of slope -/// `s = 1.375^(b/50)` from the origin, rolled off to (1, 1) by a `v^P` white-anchor term. Pins pure -/// black and white; `b = 0` is the identity. Fit to Photoshop ground truth (see modern B/C above). +/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white, +/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin +/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is +/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is +/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it +/// is the inverse of the curve raised by as much. +/// +/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50 +/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd +/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give +/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out). pub fn modern_brightness(v: f32, brightness: f32) -> f32 { - if brightness == 0.0 { + if brightness == 0.0 || !brightness.is_finite() { return v; } - let s = 1.375f32.powf(brightness / 50.0); - // Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1). - let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness }; - let v = v.clamp(0.0, 1.0); - (s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0) + Brightness::new(brightness).eval(v) +} + +/// [`modern_brightness`] for one slider value, its curve's constants worked out once. +struct Brightness { + raise: bool, + s: f32, + e: f32, + x0: f32, + /// The knee's slope over the roll-off's secant (`α` of Fritsch–Carlson); a power roll-off + /// replaces the Hermite when α² + β² > 9. + alpha: f32, + hermite: bool, +} + +/// Output level of the Brightness curve's knee. +const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0; + +impl Brightness { + fn new(brightness: f32) -> Self { + let b = brightness.abs(); + let s = 1.375f32.powf(b / 50.0); + let e = 1.0 / (1.0 + 0.084 * b); + let x0 = (BRIGHTNESS_KNEE / s).min(1.0); + let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6); + let (alpha, beta) = (s / secant, e / secant); + Brightness { raise: brightness > 0.0, s, e, x0, alpha, hermite: alpha * alpha + beta * beta <= 9.0 } + } + + fn eval(&self, v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + if self.raise { + return self.raised(v); + } + if v <= 0.0 || v >= 1.0 { + return v; + } + // The inverse of the raised curve (monotone on [0, 1]), by bisection. + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..32 { + let mid = 0.5 * (lo + hi); + if self.raised(mid) < v { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) + } + + /// The curve raised: the line, then a monotone roll-off to (1, 1). + fn raised(&self, x: f32) -> f32 { + let Brightness { s, e, x0, alpha, hermite, .. } = *self; + let y0 = BRIGHTNESS_KNEE; + if x <= x0 { + return y0.min(s * x); + } + let h = (1.0 - x0).max(1e-6); + let t = ((x - x0) / h).clamp(0.0, 1.0); + let y = if hermite { + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * h * s + (-2.0 * t3 + 3.0 * t2) + (t3 - t2) * h * e + } else { + 1.0 - (1.0 - y0) * (1.0 - t).powf(alpha) + }; + y.clamp(0.0, 1.0) + } +} + +/// Modern Brightness/Contrast as one channel's lookup table (4096 samples over 0..1): +/// [`modern_brightness`] then [`modern_contrast`], as Photoshop applies them. +pub fn modern_lut(brightness: f32, contrast: f32) -> Vec { + let step = |k: usize, n: usize| k as f32 / (n - 1) as f32; + let bright: Vec = if brightness == 0.0 || !brightness.is_finite() { + (0..LUT_SIZE).map(|k| step(k, LUT_SIZE)).collect() + } else if brightness > 0.0 { + let curve = Brightness::new(brightness); + (0..LUT_SIZE).map(|k| curve.raised(step(k, LUT_SIZE))).collect() + } else { + // Lowered: invert the raised curve, sampled four times finer, in one monotone pass. + let curve = Brightness::new(-brightness); + let fine = 4 * LUT_SIZE; + let ys: Vec = (0..fine).map(|j| curve.raised(step(j, fine))).collect(); + let mut j = 0; + (0..LUT_SIZE) + .map(|k| { + let y = step(k, LUT_SIZE); + while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) { + j += 1; + } + let (y0, y1) = (ys.get(j).copied().unwrap_or(0.0), ys.get(j + 1).copied().unwrap_or(1.0)); + let f = if y1 > y0 { ((y - y0) / (y1 - y0)).clamp(0.0, 1.0) } else { 0.0 }; + (step(j, fine) + f / (fine - 1) as f32).clamp(0.0, 1.0) + }) + .collect() + }; + bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect() } /// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve @@ -854,6 +1127,8 @@ pub fn modern_contrast(v: f32, contrast: f32) -> f32 { if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) } } +/// Photoshop posterize: `n` equal input bins over 0..=255, output levels +/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels). pub fn posterize(v: f32, levels: u32) -> f32 { let n = levels.clamp(2, 255) as f32; let x = (v.clamp(0.0, 1.0) * 255.0).round(); @@ -861,44 +1136,75 @@ pub fn posterize(v: f32, levels: u32) -> f32 { (bin * 255.0 / (n - 1.0)).floor() / 255.0 } +/// [`posterize`] in a document of `depth` (`None`: unknown, treated as 8-bit). At 16 bits +/// Photoshop bins the 16-bit sample (`⌊v · n / 32768⌋`) and lands on 8-bit levels through a +/// 16-bit step: `⌊b · 32768 / (n - 1)⌋`, converted with `⌊(o · 255 + 16384) / 32768⌋`, so +/// 127.5 comes out as 128 but 42.5 as 42 (8 bits floor them all). Exact on every sample of +/// psd-tools posterize_16bits_rgb.psd and posterize_16bits_grayscale.psd (levels 2 to 21), +/// which the 8-bit rule missed by up to half the range. +pub fn posterize_in(v: f32, levels: u32, depth: Option) -> f32 { + if depth != Some(SampleType::U16) { + return posterize(v, levels); + } + let n = f64::from(levels.clamp(2, 255)); + let x = (f64::from(v.clamp(0.0, 1.0)) * 32768.0).round(); + let bin = (x * n / 32768.0).floor().min(n - 1.0); + let step = (bin * 32768.0 / (n - 1.0)).floor(); + (((step * 255.0 + 16384.0) / 32768.0).floor() / 255.0) as f32 +} + pub fn levels(ch: &LevelsChannel, v: f32) -> f32 { levels_q(ch, v, None) } /// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole -/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole -/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 → -/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT -/// the result is a staircase that is exact at every level. +/// levels in three steps, each rounded half up to a whole level: the input-range stretch, the +/// midtone gamma ([`levels_gamma`]) and the output range. So a 44..214 range maps 45, 46, 47, 48 +/// → 1.5, 3, 4.5, 6 → 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the +/// steps), and an output range narrower than 0..255 maps neighbouring gamma levels to one level +/// (psd-tools levels_rgb: 43..48 → 58 through a 0.47 channel gamma and the master's 48..255). +/// In a LUT the result is a staircase that is exact at every level. pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option) -> f32 { let range = (ch.in_white - ch.in_black).max(1e-6); - let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) { + let quantum = quantum.filter(|q| q.is_finite() && *q >= 1.0); + let whole = |x: f32| match quantum { + Some(q) => (x * q + 0.5).floor() / q, + None => x, + }; + let u = match quantum { Some(q) => { let v = (v * q).round() / q; (((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q } None => ((v - ch.in_black) / range).clamp(0.0, 1.0), }; - let g = ch.gamma.max(0.01); - let t = if g > 1.0 { - // A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black — - // which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against - // the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact - // power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and - // sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and - // psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both). - let power = u.powf(1.0 / g); - let line = 0.93 * 2.0f32.powf(g) * u; - if power <= 1e-6 || line <= 1e-6 { - power.min(line) - } else { - let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth - (power.powf(-p) + line.powf(-p)).powf(-1.0 / p) - } - } else { - u.powf(1.0 / g) - }; - ch.out_black + t * (ch.out_white - ch.out_black) + let t = whole(levels_gamma(u, ch.gamma.max(0.01))); + whole(ch.out_black + t * (ch.out_white - ch.out_black)) +} + +/// Exponent of the shadow toe in [`levels_gamma`] (fitted; see there). +const LEVELS_TOE_POWER: f32 = 2.2; + +/// Levels' midtone gamma `g` on `u` in 0..1: `u^(1/g)`, except that above 1, where that power +/// curve is infinitely steep at black, Photoshop starts along a toe `a·u − b·u^q` of slope +/// `a = 2^g` that joins the curve with the same value and slope. `q` = [`LEVELS_TOE_POWER`] is +/// fitted on whole-level oracles at gamma 1.16, 1.22, 1.3, 1.78 and 2.0 (psd-tools levels_rgb +/// and levels_grayscale, the photoshop corpus's rgb8 and gray8 levels.psd, a measured 0..255 +/// ramp): all but 2 of their 1118 levels exact, the others within one. Below 1 the power curve is +/// exact (gamma 0.67 and 0.7: every level). +pub fn levels_gamma(u: f32, g: f32) -> f32 { + let c = 1.0 / g; + if g <= 1.0 { + return u.powf(c); + } + let (a, q) = (2f32.powf(g), LEVELS_TOE_POWER); + // Where the toe meets the curve: a·u1 − b·u1^q = u1^c and a − q·b·u1^(q−1) = c·u1^(c−1). + let u1 = ((1.0 - c / q) / (a * (1.0 - 1.0 / q))).powf(1.0 / (1.0 - c)); + if u >= u1 || u1 <= 0.0 { + return u.powf(c); + } + let b = (a * u1 - u1.powf(c)) / u1.powf(q); + a * u - b * u.powf(q) } /// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result @@ -1178,6 +1484,41 @@ mod tone_tests { /// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp /// (x = 0, 16, …, 240, 255), one row per (brightness, contrast). + /// One pixel through `adj` in a 32-bit document. + fn hdr(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply_depth(adj, &mut b, Transfer::Gamma(1.0), Some(SampleType::F32)); + [b.px[0][0], b.px[0][1], b.px[0][2]] + } + + /// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast + /// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the + /// constant, and the Black & White tint keeps values above 1. + #[test] + fn thirty_two_bit_adjustments_match_photoshop_unclipped() { + let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol); + let bc = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }; + // brightness-contrast-legacy.psd + assert!(near(hdr(&bc, [0.4910, 0.0009, 0.9301]), [0.6280, -0.0721, 1.2553], 2e-4), "{:?}", hdr(&bc, [0.4910, 0.0009, 0.9301])); + // channel-mixer.psd: pure red, green, blue and black, exactly. + let mixer = Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false }; + for (c, want) in [ + ([1.0, 0.0, 0.0], [0.7998047, 0.1245117, 0.0]), + ([0.0, 1.0, 0.0], [0.2998047, 0.9243164, 0.1992188]), + ([0.0, 0.0, 1.0], [-0.0996094, 0.0249023, 0.6992188]), + ([0.0, 0.0, 0.0], [0.0, 0.0249023, 0.0]), + ] { + assert!(near(hdr(&mixer, c), want, 1e-6), "{c:?}: {:?}", hdr(&mixer, c)); + } + // black-white-tint.psd: white tints past 1. + let bw = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: Some([0.88235295, 0.827451, 0.7019608]) }; + assert!(near(hdr(&bw, [1.0; 3]), [1.0522, 0.9973, 0.8718], 2e-4), "{:?}", hdr(&bw, [1.0; 3])); + // 8/16-bit documents still clip. + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]); + apply_depth(&bw, &mut b, Transfer::Srgb, None); + assert!(b.px[0].iter().all(|v| *v <= 1.0)); + } + #[test] fn legacy_brightness_contrast_matches_photoshop() { const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255]; @@ -1202,6 +1543,48 @@ mod tone_tests { } } + /// Pixel → Photoshop's split. 8-bit from psd-tools threshold_rgb.psd: the first three are + /// white though their Rec. 601 grey rounds below 60, and (131, 109, 99) sits exactly on 114.5 + /// at level 115 (halves round down). 16-bit from threshold_16bits_rgb.psd and the photoshop + /// corpus's rgb16 threshold-128.psd, whose mid grey 16448 is level 128 in 16 bits. + #[test] + fn threshold_splits_on_the_luminosity_like_photoshop() { + let c8 = |r: u8, g: u8, b: u8| [r, g, b].map(|v| f32::from(v) / 255.0); + for (c, level, white) in + [(c8(87, 51, 31), 60u8, true), (c8(90, 49, 33), 60, true), (c8(68, 57, 50), 60, true), (c8(48, 63, 72), 60, false), (c8(131, 109, 99), 115, false)] + { + assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U8)), white, "8-bit {c:?} at {level}"); + } + let c16 = |r: u16, g: u16, b: u16| [r, g, b].map(|v| f32::from(v) / 32768.0); + for (c, level, white) in [ + (c16(8995, 7196, 6425), 60u8, false), + (c16(11180, 6554, 3984), 60, false), + (c16(16448, 16448, 16448), 128, true), + (c16(16447, 16447, 16447), 128, false), + ] { + assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U16)), white, "16-bit {c:?} at {level}"); + } + // Through the adjustment: neutral black or white. + let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [87.0 / 255.0, 51.0 / 255.0, 31.0 / 255.0, 1.0]); + apply_depth(&Adjustment::Threshold { level: 60.0 / 255.0 }, &mut b, Transfer::Srgb, Some(SampleType::U8)); + assert_eq!(b.px[0], [1.0, 1.0, 1.0, 1.0]); + } + + /// 16-bit sample → Photoshop's 8-bit level, from psd-tools posterize_16bits_rgb.psd: bins + /// split on the 16-bit sample (10794 and 10923 straddle a third of 32768), and the middle of 3 + /// levels is 128 where 8 bits give 127, while 21 levels keep 25 for 25.5. + #[test] + fn sixteen_bit_posterize_matches_photoshop() { + let points: [(u32, u16, u8); 9] = + [(3, 10794, 0), (3, 10923, 128), (3, 21845, 128), (3, 21974, 255), (13, 2504, 0), (13, 2542, 21), (13, 7453, 42), (13, 7582, 64), (21, 3341, 25)]; + for (levels, x, want) in points { + let got = posterize_in(f32::from(x) / 32768.0, levels, Some(SampleType::U16)); + assert!((got * 255.0 - f32::from(want)).abs() < 1e-3, "{levels} levels at {x}: {} vs Photoshop {want}", got * 255.0); + } + // 8 bits keep their rule. + assert_eq!(posterize_in(128.0 / 255.0, 3, Some(SampleType::U8)), posterize(128.0 / 255.0, 3)); + } + #[test] fn color_lookup_with_an_overflowing_stored_size_is_the_identity() { // A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past @@ -1254,6 +1637,78 @@ mod tone_tests { } } + /// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold + /// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the + /// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with + /// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised + /// highlights (their gammas cancel: a straight line between the two points). + #[test] + fn color_balance_whole_settings_match_photoshop() { + const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]); + let cases: [Case; 6] = [ + ( + [[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]], + false, + [[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + false, + [ + [0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255], + [0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255], + [0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255], + ], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + true, + [ + [0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255], + [0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255], + [0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255], + ], + ), + ( + [[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]], + true, + [ + [0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255], + [0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255], + [0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255], + ], + ), + ( + [[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]], + true, + [X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]], + ), + ( + [[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]], + false, + [ + [0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255], + [0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255], + [0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255], + ], + ), + ]; + for (tones, preserve, want) in cases { + for (ch, row) in want.iter().enumerate() { + for (x, w) in X.iter().zip(row) { + let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32; + assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}"); + } + } + } + // Hostile input stays in range. + for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] { + let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v); + assert!((0.0..=1.0).contains(&out), "{v}: {out}"); + } + } + #[test] fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() { // A shared offset depends on the two extremes, not the sum of moved sliders. @@ -1525,6 +1980,18 @@ mod tone_tests { } } + #[test] + fn gradient_map_follows_the_w3c_luminosity() { + // Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and + // rgb16 gradient-map.psd), not the Rec. 601 grey. + let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; + for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b); + assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]); + } + } + #[test] fn gradient_map_dither_stays_close() { let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; @@ -1600,6 +2067,64 @@ mod tone_tests { assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3)); } + /// CMYK posterizes the stored value 1 - ink: with 3 levels an ink of 85 (stored 170) lands on + /// the middle step, 128 (stored 127), where posterizing the ink would drop it to none (psd-tools + /// posterize_cmyk.psd, levels 3 to 21, exact so). + #[test] + fn cmyk_posterize_works_on_the_stored_values() { + let mut ink = [85.0 / 255.0; 4]; + stored_channels(&Adjustment::Posterize { levels: 3 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ink); + assert!(ink.iter().all(|v| (v * 255.0 - 128.0).abs() < 1e-3), "{ink:?}"); + assert_eq!(posterize(85.0 / 255.0, 3), 0.0, "posterizing the ink itself"); + } + + #[test] + fn cmyk_threshold_compares_the_inks_plain_luma() { + assert_eq!(ink_luma([0.0; 4]), 1.0); + assert_eq!(ink_luma([0.0, 0.0, 0.0, 1.0]), 0.0); + // Two greys either side of level 128 in photoshop corpus cmyk8 threshold-128.psd: black, + // then white (8-bit inks). + let at = |i: [f32; 4]| { + let mut ch = i.map(|v| v / 255.0); + stored_channels(&Adjustment::Threshold { level: 128.0 / 255.0 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ch); + ch + }; + assert_eq!(at([144.0, 122.0, 121.0, 35.0]), [0.0, 0.0, 0.0, 1.0]); + assert_eq!(at([131.0, 110.0, 110.0, 19.0]), [0.0; 4]); + } + + #[test] + fn cmyk_brightness_contrast_curves_each_ink_from_paper() { + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; + // The tone curve runs on each ink's stored value, 1 - ink (photoshop corpus cmyk8 + // brightness-contrast.psd and -legacy.psd): raising brightness takes ink away, and paper + // and solid ink stay put. + let red = [0.9, 0.2, 0.1]; + for legacy in [false, true] { + let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy }; + let table = ink_brightness_contrast_lut(25.0, 30.0, legacy); + let want = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - lut(&table, 1.0 - v))); + assert!(near(in_cmyk(&adj, red), want, 2e-3), "legacy {legacy}"); + assert!(near(in_cmyk(&adj, [1.0; 3]), [1.0; 3], 2e-3), "legacy {legacy}: paper"); + let solid = cmyk_to_rgb([1.0; 4]); + assert!(near(in_cmyk(&adj, solid), cmyk_to_rgb(rgb_to_cmyk(solid).map(|v| ink_through(&table, v))), 2e-3)); + } + // Legacy +25 / +30 on Photoshop's own 16-bit inks (photoshop corpus cmyk16 + // brightness-contrast-legacy.psd): contrast pivots at 128 in ink, 127 stored. + let legacy = ink_brightness_contrast_lut(25.0, 30.0, true); + for (ink, ps) in [(139.0, 108.0), (160.0, 137.99), (172.0, 155.13), (183.0, 170.85), (196.0, 189.42), (98.0, 49.42), (128.0, 92.28), (168.0, 149.42)] { + let got = ink_through(&legacy, ink / 255.0) * 255.0; + assert!((got - ps).abs() < 0.1, "ink {ink}: {got} vs Photoshop {ps}"); + } + let brighter = Adjustment::BrightnessContrast { brightness: 60.0, contrast: 0.0, legacy: false }; + let ink = ink_brightness_contrast_lut(60.0, 0.0, false); + assert!(ink_through(&ink, 0.5) < 0.5 && ink_through(&ink, 0.0) == 0.0 && (ink_through(&ink, 1.0) - 1.0).abs() < 1e-6); + // On the screen colour it would land elsewhere. + let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]); + apply(&brighter, &mut rgb); + assert!(!near(in_cmyk(&brighter, red), [rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]], 0.01)); + } + #[test] fn lab_posterize_steps_l_a_and_b() { let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0)); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8813b122d9..b742540b95 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -25,6 +25,7 @@ pub mod gradient_fill; pub mod masks; pub mod multichannel; pub mod native; +pub mod native_lab; pub mod pattern; pub mod proxy; pub mod psblend; @@ -128,6 +129,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { return Buffer::transparent(Rect::EMPTY); } + // A CMYK stack with adjustment layers the inks carry exactly composites on its inks. + if let Some(buf) = native::render_cmyk(doc, rect, tile) { + return buf; + } + // A Lab stack with adjustment layers that work on L, a and b composites on its stored values. + if let Some(buf) = native_lab::render_lab(doc, rect, tile) { + return buf; + } cx.set_fx_clip(rect); if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) { return photocraft_color::convert::with_cmyk_space(cmyk, || { @@ -260,7 +269,8 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 { /// otherwise count as content: the result would span the whole canvas. /// /// A CMYK document of plain Normal layers is composited in its ink values instead -/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778). +/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778), +/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]). pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { composite_to_surface(doc, doc.bounds(), fmt, background) } @@ -273,6 +283,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P { return s; } + if (background.is_none() || paper) + && let Some(s) = native_lab::flatten_lab(doc, rect, fmt, paper) + { + return s; + } let mut s = Surface::new(fmt); let n = fmt.channels(); let empty = s.default_pixel(); @@ -326,6 +341,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { fx_maps: Default::default(), fx_clip: Default::default(), doc: None, + layer_keys: Default::default(), }, advanced::Scope::ROOT, ); @@ -523,6 +539,60 @@ struct Ctx<'a> { fx_clip: std::sync::Mutex>, /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. doc: Option<&'a Document>, + /// Effect-map identities of this render's document layers (see [`LayerKeys`]). + layer_keys: LayerKeys, +} + +/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends +/// on the layer alone, not on the tile, so it is computed once per layer per render instead of +/// once per tile. Only layers that sit in the document tree are memoised by address: the document +/// is borrowed for the whole render, so no other object can live at such an address. Temporary +/// copies (`advanced::effects_source`) are never in the tree and always take the full path. +#[derive(Default)] +struct LayerKeys { + /// Addresses of every layer in the document tree, collected on first use. + in_doc: std::sync::OnceLock>, + ids: std::sync::Mutex>, + /// Identities computed (tests count them). + #[cfg(test)] + built: std::sync::atomic::AtomicUsize, +} + +impl LayerKeys { + fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { + let addr = std::ptr::from_ref(layer) as usize; + let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); + if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { + return *id; + } + let id = self.compute(layer); + if in_doc { + self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + } + id + } + + fn compute(&self, layer: &Layer) -> u64 { + use std::hash::Hasher; + #[cfg(test)] + self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut h = std::collections::hash_map::DefaultHasher::new(); + layer_identity(layer, &mut h); + h.finish() + } +} + +/// Addresses of all layers of `doc` (groups' children included). +fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + let mut todo: Vec<&Layer> = doc.layers.iter().collect(); + while let Some(l) = todo.pop() { + out.insert(std::ptr::from_ref(l) as usize); + if let LayerContent::Group(g) = &l.content { + todo.extend(g.children.iter()); + } + } + out } impl<'a> Ctx<'a> { @@ -538,6 +608,7 @@ impl<'a> Ctx<'a> { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + layer_keys: Default::default(), } } @@ -1290,11 +1361,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Pass-through groups composite their children straight into the backdrop. Below 100% fill // Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an - // adjustment inside no longer reaches the layers beneath). + // adjustment inside no longer reaches the layers beneath), and so it does for a group that + // is the base of a clipping group and holds an adjustment layer (psd-tools + // passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone), + // while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode). + let clip_base_adjusts = clipped.iter().any(|c| c.visible) + && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !effects::has_effects(layer) + && !clip_base_adjusts { let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some(); let has_clipped = clipped.iter().any(|c| c.visible); @@ -1603,7 +1680,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc u64 { h.finish() } -/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the -/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather -/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it). +/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. +/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector +/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's +/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). pub fn feather_sigma(px: f32) -> f32 { if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } } @@ -188,37 +189,73 @@ pub fn has_feather(layer: &Layer) -> bool { || layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0) } +/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just +/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct BoxPass { + r: usize, + end: f32, +} + +impl BoxPass { + /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et + /// al. 2011), or `None` when there is nothing to blur. + fn for_sigma(sigma: f32) -> Option { + if !sigma.is_finite() || sigma <= 0.0 { + return None; + } + // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. + let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; + // The largest whole radius whose box variance r(r+1)/3 is at most v. + let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; + let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; + while r > 0 && var(r) > v { + r -= 1; + } + while var(r + 1) <= v { + r += 1; + } + let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); + let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); + Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) + } +} + /// Apply one of the three box passes to a single line. `scratch` preserves the input while /// the sliding sum writes the output, matching the previous sequential implementation exactly. -fn blur_line(line: &mut [f32], scratch: &mut [f32], r: usize) { +fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { if line.is_empty() { return; } - let Ok(radius) = isize::try_from(r) else { return }; + let Ok(radius) = isize::try_from(pass.r) else { return }; scratch.copy_from_slice(line); let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; let mut acc: f32 = (-radius..=radius).map(get).sum(); - let n = (2 * r + 1) as f32; + let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; + // The sample leaving the window is the next one's low end weight. + let mut below = get(-radius - 1); for (i, dst) in line.iter_mut().enumerate() { - *dst = acc / n; let i = i as isize; - acc += get(i + radius + 1) - get(i - radius); + let (above, leaving) = (get(i + radius + 1), get(i - radius)); + *dst = (acc + pass.end * (below + above)) / n; + acc += above - leaving; + below = leaving; } } /// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used /// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon /// work here can deadlock when every worker waits on the same OnceLock (#276). -fn blur_rows_chunk(v: &mut [f32], w: usize, r: usize) { +fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { let mut scratch = vec![0.0; w]; for row in v.chunks_mut(w) { for _ in 0..3 { - blur_line(row, &mut scratch, r); + blur_line(row, &mut scratch, pass); } } } -fn blur_rows(v: &mut [f32], w: usize, r: usize) { +fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { if w == 0 || v.is_empty() { return; } @@ -229,14 +266,14 @@ fn blur_rows(v: &mut [f32], w: usize, r: usize) { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) }; if threads == 1 { - blur_rows_chunk(v, w, r); + blur_rows_chunk(v, w, pass); return; } let rows_per_worker = h.div_ceil(threads); let chunk_len = rows_per_worker.saturating_mul(w); std::thread::scope(|scope| { for chunk in v.chunks_mut(chunk_len) { - scope.spawn(move || blur_rows_chunk(chunk, w, r)); + scope.spawn(move || blur_rows_chunk(chunk, w, pass)); } }); } @@ -256,35 +293,34 @@ fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { } } -/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which -/// is exact here since the planes extend into constant mask regions). The axes are processed in -/// parallel on bounded OS threads, preserving each line's floating-point operation order. +/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is +/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into +/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like +/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels +/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes +/// are processed in parallel on bounded OS threads, preserving each line's floating-point +/// operation order. fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { - if sigma <= 0.0 || w == 0 || h == 0 || v.len() != w.saturating_mul(h) { - return; - } - // Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi). - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; - if r == 0 { + if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { return; } + let Some(pass) = BoxPass::for_sigma(sigma) else { return }; // Reserve before modifying the input so allocation failure can use the sequential fallback // without leaving a half-blurred mask. let mut transposed = Vec::new(); if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, r); + gaussian_sequential(v, w, h, pass); return; } transposed.resize(v.len(), 0.0); - blur_rows(v, w, r); + blur_rows(v, w, pass); transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, r); + blur_rows(&mut transposed, h, pass); transpose_into(&transposed, v, h, w); } /// Allocation-light reference path used if parallel transpose storage cannot be reserved. -fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) { +fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { let mut line = Vec::new(); let mut scratch = Vec::new(); let len = w.max(h); @@ -300,7 +336,7 @@ fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) { line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; } for _ in 0..3 { - blur_line(&mut line[..line_len], &mut scratch[..line_len], r); + blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); } for i in 0..line_len { if axis { @@ -693,14 +729,55 @@ mod tests { } } + /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole + /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). + #[test] + fn feather_blur_has_the_exact_variance() { + for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { + let (w, c) = (801usize, 400usize); + let mut v = vec![0.0f32; w]; + v[c] = 1.0; + gaussian(&mut v, w, 1, sigma); + // Within the kernel's reach (the running sums leave ~1e-8 behind further out). + let reach = (8.0 * sigma) as usize + 4; + let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); + assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); + assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); + } + assert_eq!(BoxPass::for_sigma(0.0), None); + assert_eq!(BoxPass::for_sigma(f32::NAN), None); + assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); + } + + /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools + /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it + /// within 1.5 levels, where whole-pixel boxes were 4 off. + #[test] + fn a_feathered_edge_is_a_gaussian_step() { + // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). + let erf = |x: f64| { + let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); + let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); + y.copysign(x) + }; + let (w, edge, sigma) = (240usize, 120usize, 6.0f32); + let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); + gaussian(&mut v, w, 1, sigma); + for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { + let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); + let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); + // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). + assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); + } + } + #[test] fn parallel_gaussian_matches_the_sequential_reference() { let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); let mut sequential = parallel.clone(); - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; - gaussian_sequential(&mut sequential, w, h, r); + let pass = BoxPass::for_sigma(sigma).unwrap(); + gaussian_sequential(&mut sequential, w, h, pass); gaussian(&mut parallel, w, h, sigma); assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); } @@ -711,12 +788,11 @@ mod tests { use std::time::Instant; let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; + let pass = BoxPass::for_sigma(sigma).unwrap(); let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); let mut sequential = source.clone(); let start = Instant::now(); - gaussian_sequential(&mut sequential, w, h, r); + gaussian_sequential(&mut sequential, w, h, pass); let sequential_time = start.elapsed(); let mut parallel = source; let start = Instant::now(); diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs index 9eafd18cc6..31e17f3ea2 100644 --- a/crates/compose/src/native.rs +++ b/crates/compose/src/native.rs @@ -1,85 +1,255 @@ -//! Mode-native flattening of plain CMYK layer stacks (#3778). +//! Mode-native compositing of plain CMYK layer stacks (#3778). //! //! The compositor works in display RGB, so flattening a CMYK document through it reads every ink //! value through the profile and separates it again: even a lone Background comes back with //! different ink. When every visible layer is a plain raster layer in Normal mode (no masks, //! effects, clipping or advanced blending), the composite needs no colour conversion at all: //! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored -//! values and an opaque layer keeps them exactly. +//! values and an opaque layer keeps them exactly. A layer mask (unfeathered) scales the mix, as +//! it does there. +//! +//! Plain adjustment layers that Photoshop evaluates on the inks (Invert, Posterize, Threshold, +//! Brightness/Contrast, Hue/Saturation, Color Balance, and Levels and Curves on CMYK channels) +//! join such a stack too: on the +//! stored inks their rules are exact, where the RGB compositor can only apply them to a separation +//! of its RGB composite, whose black differs from the file's. Such stacks are also rendered for +//! display this way ([`render_cmyk`]); stacks of raster layers alone keep the RGB compositor there. use photocraft_color::blend::BlendMode; -use photocraft_color::{ColorMode, PixelFormat}; -use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::adjust::ToneSpace; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerMask}; use photocraft_geom::Rect; use photocraft_raster::Surface; +use crate::{Buffer, adjust}; + /// Process channels of a CMYK pixel. const INKS: usize = 4; -/// A layer the native path can composite: a visible raster layer the compositor would only blend -/// Normally at its opacity. -fn plain(layer: &Layer) -> Option<&Surface> { - let LayerContent::Raster(s) = &layer.content else { return None }; - let simple = layer.blend == BlendMode::Normal +/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask: +/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced +/// blending. +fn simple(layer: &Layer) -> bool { + layer.blend == BlendMode::Normal && !layer.clipped - && layer.mask.as_ref().is_none_or(|m| !m.enabled) + && !crate::masks::has_feather(layer) && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) && !crate::effects::has_effects(layer) && layer.excluded_channels == 0 && layer.blend_if.is_default() && layer.advanced.is_default() && layer.video.is_none() - && s.format().mode == ColorMode::Cmyk; - simple.then_some(s) +} + +/// A layer the native path can composite: a visible raster layer the compositor would only blend +/// Normally at its opacity. +fn plain(layer: &Layer) -> Option<&Surface> { + let LayerContent::Raster(s) = &layer.content else { return None }; + (simple(layer) && s.format().mode == ColorMode::Cmyk).then_some(s) +} + +/// An adjustment on straight inks, prepared once per composite. +enum InkOp<'a> { + /// Each ink's stored value (1 - ink) through its channel's table: C, M, Y, K + /// (`adjust::ink_through`; Levels, Curves and Brightness/Contrast). + Tables([Vec; INKS]), + /// Invert, Posterize or Threshold (`adjust::stored_channels`). + Rules(&'a Adjustment), + /// Hue/Saturation or Color Balance on C, M and Y as inverted RGB (`adjust::apply_cmy`). + Cmy(&'a Adjustment), +} + +impl InkOp<'_> { + /// The adjusted inks of each pixel of `inks` that has coverage (its fifth value). + fn apply(&self, inks: &[[f32; INKS + 1]], depth: SampleType) -> Vec<[f32; INKS]> { + let mut out: Vec<[f32; INKS]> = inks.iter().map(|d| [d[0], d[1], d[2], d[3]]).collect(); + match self { + InkOp::Tables(t) => { + for ink in &mut out { + for (v, table) in ink.iter_mut().zip(t) { + *v = adjust::ink_through(table, *v); + } + } + } + InkOp::Rules(adj) => out.iter_mut().for_each(|ink| adjust::stored_channels(adj, ColorMode::Cmyk, Some(depth), ink)), + InkOp::Cmy(adj) => adjust::apply_cmy(adj, &mut out), + } + out + } +} + +/// The ink evaluation of an adjustment layer the native path can composite. +fn ink_op(layer: &Layer, depth: SampleType) -> Option> { + let LayerContent::Adjustment(adj) = &layer.content else { return None }; + if !simple(layer) { + return None; + } + match adj { + Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. } => Some(InkOp::Rules(adj)), + _ if adjust::cmy_adjustment(adj) => Some(InkOp::Cmy(adj)), + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + let t = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + Some(InkOp::Tables([t.clone(), t.clone(), t.clone(), t])) + } + Adjustment::Levels { space: ToneSpace::Cmyk, .. } | Adjustment::Curves { space: ToneSpace::Cmyk, .. } => { + Some(InkOp::Tables(adjust::tone_luts_depth(adj, Some(depth)))) + } + _ => None, + } +} + +/// One step of a native stack, at the layer's opacity × fill, through its mask. +enum Item<'a> { + Layer(&'a Surface, f32, Option<&'a LayerMask>), + Adjust(InkOp<'a>, f32, Option<&'a LayerMask>), +} + +/// Groups nested deeper than this keep the RGB compositor. +const MAX_DEPTH: usize = 64; + +/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group +/// at full opacity without a mask is inlined: pass-through groups composite their children straight +/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal +/// composites associatively; an adjustment inside an isolated group would only see the group's +/// own content, so such a group keeps the RGB compositor (`adjustments` false). +fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec>, level: usize) -> Option<()> { + if level > MAX_DEPTH { + return None; + } + for layer in layers.iter().filter(|l| l.visible) { + let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); + let mask = layer.mask.as_ref().filter(|m| m.enabled); + if let LayerContent::Group(g) = &layer.content { + let open = g.artboard.is_none() + && alpha >= 1.0 + && mask.is_none() + && !layer.clipped + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default(); + match layer.blend { + BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?, + BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?, + _ => return None, + } + continue; + } + let item = match plain(layer) { + Some(s) => Item::Layer(s, alpha, mask), + None if adjustments => Item::Adjust(ink_op(layer, depth)?, alpha, mask), + None => return None, + }; + items.push(item); + } + Some(()) +} + +/// A CMYK document's visible layers, prepared for compositing on their ink values. +pub struct InkStack<'a> { + items: Vec>, + depth: SampleType, +} + +impl<'a> InkStack<'a> { + /// `None` unless `doc` is CMYK and every visible layer is a plain raster layer, a plain + /// adjustment layer the inks carry exactly, or a group that can be inlined (see the module + /// docs and [`collect`]). + pub fn new(doc: &'a Document) -> Option { + if doc.mode != ColorMode::Cmyk { + return None; + } + let mut items = Vec::new(); + collect(&doc.layers, doc.depth, true, &mut items, 0)?; + Some(InkStack { items, depth: doc.depth }) + } + + /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). + pub fn adjusts(&self) -> bool { + self.items.iter().any(|i| matches!(i, Item::Adjust(..))) + } + + /// Straight C, M, Y, K and alpha over `rect`, row-major, into `acc` (`src` and `mv` are + /// scratch). + fn composite(&self, rect: Rect, acc: &mut Vec<[f32; INKS + 1]>, src: &mut Vec, mv: &mut Vec) { + let n = rect.width() as usize * rect.height() as usize; + acc.clear(); + acc.resize(n, [0.0f32; INKS + 1]); + // The mask's value at pixel `i` (1 without one). + let masked = |mask: Option<&LayerMask>, mv: &mut Vec| match mask { + Some(m) => m.values_into(rect, mv), + None => { + mv.clear(); + mv.resize(n, 1.0); + } + }; + for item in &self.items { + match item { + Item::Layer(surf, opacity, mask) => { + let opacity = *opacity; + if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { + continue; + } + let sf = surf.format(); + let sn = sf.channels(); + surf.read_region_into(rect, src); + masked(*mask, mv); + for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) { + let sa = if sf.alpha { s.get(INKS).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0); + if sa <= 0.0 { + continue; + } + let oa = sa + d[INKS] * (1.0 - sa); + let keep = d[INKS] * (1.0 - sa); + for (c, v) in s.iter().take(INKS).enumerate() { + // An opaque pixel replaces what is below exactly (no rounding through the mix). + d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa }; + } + d[INKS] = oa; + } + } + Item::Adjust(op, opacity, mask) => { + if *opacity <= 0.0 { + continue; + } + masked(*mask, mv); + let adjusted = op.apply(acc, self.depth); + for ((d, k), ink) in acc.iter_mut().zip(mv.iter()).zip(&adjusted) { + let mix = opacity * k.clamp(0.0, 1.0); + if d[INKS] <= 0.0 || mix <= 0.0 { + continue; + } + for (c, v) in ink.iter().enumerate() { + d[c] += (v - d[c]) * mix; + } + } + } + } + } + } } /// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the /// document isn't CMYK or a visible layer needs the full compositor (see the module docs). /// `paper` flattens onto white paper (no ink) and makes the result opaque. pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option { - if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) { + if fmt.mode != ColorMode::Cmyk { return None; } - let mut stack = Vec::new(); - for layer in doc.layers.iter().filter(|l| l.visible) { - let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); - stack.push((plain(layer)?, alpha)); - } + let stack = InkStack::new(doc)?; let mut out = Surface::new(fmt); if rect.is_empty() { return Some(out); } let n = fmt.channels(); - let w = rect.width() as usize; let rows = crate::band_rows_for(rect.width(), 0); - let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new()); + let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let mut y = rect.y0; while y < rect.y1 { let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1)); - // Straight C, M, Y, K, alpha per pixel, starting transparent. - acc.clear(); - acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]); - for &(surf, opacity) in &stack { - if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { - continue; - } - let sf = surf.format(); - let sn = sf.channels(); - surf.read_region_into(band, &mut src); - for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) { - let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity; - if sa <= 0.0 { - continue; - } - let oa = sa + d[INKS] * (1.0 - sa); - let keep = d[INKS] * (1.0 - sa); - for c in 0..INKS { - // An opaque pixel replaces what is below exactly (no rounding through the mix). - d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa }; - } - d[INKS] = oa; - } - } + stack.composite(band, &mut acc, &mut src, &mut mv); data.clear(); data.resize(acc.len() * n, 0.0); for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) { @@ -99,3 +269,41 @@ pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) - out.prune(); Some(out) } + +/// The display composite (straight RGBA, through the document's CMYK profile) of a CMYK document +/// whose stack has adjustment layers the inks carry exactly, worked out on the inks; `None` for +/// any other document, which the RGB compositor renders. Rows of `tile` render in parallel. +pub fn render_cmyk(doc: &Document, rect: Rect, tile: i32) -> Option { + let stack = InkStack::new(doc).filter(InkStack::adjusts)?; + let cmyk = crate::cmyk_space(doc); + let w = rect.width() as usize; + let mut out = Buffer::transparent(rect); + if w == 0 || rect.is_empty() { + return Some(out); + } + let rows = tile.max(1) as usize; + let band = |i: usize, px: &mut [[f32; 4]]| { + let y0 = rect.y0.saturating_add((i * rows) as i32); + let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32)); + let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new()); + stack.composite(r, &mut acc, &mut src, &mut mv); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + for (o, p) in px.iter_mut().zip(&acc) { + if p[INKS] > 0.0 { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]); + *o = [r, g, b, p[INKS]]; + } + } + }); + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px)); + } + #[cfg(target_arch = "wasm32")] + for (i, px) in out.px.chunks_mut(w * rows).enumerate() { + band(i, px); + } + Some(out) +} diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs new file mode 100644 index 0000000000..0db59499de --- /dev/null +++ b/crates/compose/src/native_lab.rs @@ -0,0 +1,253 @@ +//! Lab-native compositing of plain Lab layer stacks. +//! +//! The compositor works in display RGB, so a Lab document's layers are read through `lab_to_srgb`, +//! which clips colours outside the display gamut, and adjustments Photoshop evaluates on L, a and +//! b ([`adjust::lab_stored_op`]) can then only see those clipped colours: an inverted or levelled +//! saturated colour comes out several levels off. When every visible layer is a plain Lab raster +//! layer in Normal mode or such an adjustment layer, the stack is worked out on the stored values +//! instead: Normal mixes L, a and b linearly, as Photoshop does in Lab, and an opaque layer keeps +//! them exactly. A layer mask (unfeathered) scales the mix. +//! +//! Stacks with such adjustment layers render for display this way ([`render_lab`]); flattening +//! ([`flatten_lab`]) keeps the stored values for any plain stack. + +use photocraft_color::blend::BlendMode; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use crate::Buffer; +use crate::adjust::{self, LabOp}; + +/// Colour channels of a Lab pixel. +const LAB: usize = 3; + +/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask: +/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced +/// blending. +fn simple(layer: &Layer) -> bool { + layer.blend == BlendMode::Normal + && !layer.clipped + && !crate::masks::has_feather(layer) + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default() + && layer.video.is_none() +} + +/// One step of a native stack, at the layer's opacity × fill, through its mask. +enum Item<'a> { + Layer(&'a Surface, f32, Option<&'a LayerMask>), + Adjust(LabOp<'a>, f32, Option<&'a LayerMask>), +} + +/// Groups nested deeper than this keep the RGB compositor. +const MAX_DEPTH: usize = 64; + +/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group +/// at full opacity without a mask is inlined: pass-through groups composite their children straight +/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal +/// composites associatively; an adjustment inside an isolated group would only see the group's +/// own content, so such a group keeps the RGB compositor (`adjustments` false). +fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec>, level: usize) -> Option<()> { + if level > MAX_DEPTH { + return None; + } + for layer in layers.iter().filter(|l| l.visible) { + let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); + let mask = layer.mask.as_ref().filter(|m| m.enabled); + let item = match &layer.content { + LayerContent::Group(g) => { + let open = g.artboard.is_none() + && alpha >= 1.0 + && mask.is_none() + && !layer.clipped + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default(); + match layer.blend { + BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?, + BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?, + _ => return None, + } + continue; + } + LayerContent::Raster(s) if simple(layer) && s.format().mode == ColorMode::Lab => Item::Layer(s, alpha, mask), + LayerContent::Adjustment(adj) if adjustments && simple(layer) => Item::Adjust(adjust::lab_stored_op(adj, Some(depth))?, alpha, mask), + _ => return None, + }; + items.push(item); + } + Some(()) +} + +/// A Lab document's visible layers, prepared for compositing on their stored L, a and b. +pub struct LabStack<'a> { + items: Vec>, +} + +impl<'a> LabStack<'a> { + /// `None` unless `doc` is Lab and every visible layer is a plain Lab raster layer, an + /// adjustment layer [`adjust::lab_stored_op`] evaluates, or a group that can be inlined (see + /// the module docs and [`collect`]). + pub fn new(doc: &'a Document) -> Option { + if doc.mode != ColorMode::Lab { + return None; + } + let mut items = Vec::new(); + collect(&doc.layers, doc.depth, true, &mut items, 0)?; + Some(LabStack { items }) + } + + /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). + pub fn adjusts(&self) -> bool { + self.items.iter().any(|i| matches!(i, Item::Adjust(..))) + } + + /// Straight L, a, b and alpha over `rect`, row-major, into `acc` (`src` and `mv` are scratch). + fn composite(&self, rect: Rect, acc: &mut Vec<[f32; LAB + 1]>, src: &mut Vec, mv: &mut Vec) { + let n = rect.width() as usize * rect.height() as usize; + acc.clear(); + acc.resize(n, [0.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL, 0.0]); + // The mask's value at each pixel (1 without one). + let masked = |mask: Option<&LayerMask>, mv: &mut Vec| match mask { + Some(m) => m.values_into(rect, mv), + None => { + mv.clear(); + mv.resize(n, 1.0); + } + }; + for item in &self.items { + match item { + Item::Layer(surf, opacity, mask) => { + let opacity = *opacity; + if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().get(LAB).is_none_or(|a| *a <= 0.0)) { + continue; + } + let sf = surf.format(); + let sn = sf.channels(); + surf.read_region_into(rect, src); + masked(*mask, mv); + for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) { + let sa = if sf.alpha { s.get(LAB).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0); + if sa <= 0.0 { + continue; + } + let oa = sa + d[LAB] * (1.0 - sa); + let keep = d[LAB] * (1.0 - sa); + for (c, v) in s.iter().take(LAB).enumerate() { + // An opaque pixel replaces what is below exactly (no rounding through the mix). + d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa }; + } + d[LAB] = oa; + } + } + Item::Adjust(op, opacity, mask) => { + if *opacity <= 0.0 { + continue; + } + masked(*mask, mv); + for (d, k) in acc.iter_mut().zip(mv.iter()) { + let mix = opacity * k.clamp(0.0, 1.0); + if d[LAB] <= 0.0 || mix <= 0.0 { + continue; + } + let mut lab = [d[0], d[1], d[2]]; + op(&mut lab); + for (c, v) in lab.iter().enumerate() { + d[c] += (v - d[c]) * mix; + } + } + } + } + } + } +} + +/// The composite of `doc` over `rect` in `fmt`, worked out on stored L, a and b, or `None` when +/// the document isn't Lab, `fmt` isn't, or a visible layer needs the full compositor (see the +/// module docs). `white` flattens onto white (L 100, neutral a and b) and makes the result opaque. +pub fn flatten_lab(doc: &Document, rect: Rect, fmt: PixelFormat, white: bool) -> Option { + if fmt.mode != ColorMode::Lab { + return None; + } + let stack = LabStack::new(doc)?; + let mut out = Surface::new(fmt); + if rect.is_empty() { + return Some(out); + } + let n = fmt.channels(); + let empty = out.default_pixel(); + let rows = crate::band_rows_for(rect.width(), 0); + let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut y = rect.y0; + while y < rect.y1 { + let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1)); + stack.composite(band, &mut acc, &mut src, &mut mv); + data.clear(); + data.resize(acc.len() * n, 0.0); + for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) { + let a = p[LAB]; + if white { + let paper = [1.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL]; + for c in 0..LAB { + o[c] = p[c] * a + paper[c] * (1.0 - a); + } + if fmt.alpha { + o[LAB] = 1.0; + } + } else if a <= 0.0 { + o.copy_from_slice(&empty[..n]); + } else { + o[..LAB].copy_from_slice(&p[..LAB]); + if fmt.alpha { + o[LAB] = a; + } + } + } + out.write_region(band, &data); + y = band.y1; + } + out.prune(); + Some(out) +} + +/// The display composite (straight RGBA) of a Lab document whose stack has adjustment layers that +/// work on L, a and b, worked out on the stored values; `None` for any other document, which the +/// RGB compositor renders. Rows of `tile` render in parallel. +pub fn render_lab(doc: &Document, rect: Rect, tile: i32) -> Option { + let stack = LabStack::new(doc).filter(LabStack::adjusts)?; + let w = rect.width() as usize; + let mut out = Buffer::transparent(rect); + if w == 0 || rect.is_empty() { + return Some(out); + } + let rows = tile.max(1) as usize; + let band = |i: usize, px: &mut [[f32; 4]]| { + let y0 = rect.y0.saturating_add((i * rows) as i32); + let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32)); + let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new()); + stack.composite(r, &mut acc, &mut src, &mut mv); + for (o, p) in px.iter_mut().zip(&acc) { + if p[LAB] > 0.0 { + let [r, g, b] = photocraft_color::convert::lab_to_srgb([p[0] * 100.0, p[1] * 255.0 - 128.0, p[2] * 255.0 - 128.0]); + *o = [r, g, b, p[LAB]]; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px)); + } + #[cfg(target_arch = "wasm32")] + for (i, px) in out.px.chunks_mut(w * rows).enumerate() { + band(i, px); + } + Some(out) +} diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 84c9348029..f48f10d83f 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -4,8 +4,8 @@ //! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP //! document would cost seconds and gigabytes. -use photocraft_doc::{Document, Layer, LayerContent, Size}; -use photocraft_geom::Rect; +use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject}; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; /// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). @@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) { } LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => shrink_smart(so, k), LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} } } +/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the +/// placement too. A preview that adds a smart filter re-renders the source through the placement; +/// left in full-size document coordinates, that render came out `k` times too large on the proxy +/// canvas, so the Curves preview on a smart object zoomed in (#5766). +fn shrink_smart(so: &mut SmartObject, k: u32) { + so.cache = so.cache.as_ref().map(|s| downsample(s, k)); + if let Some(m) = &mut so.filter_mask { + m.surface = downsample(&m.surface, k); + } + let s = 1.0 / f64::from(k); + so.transform = Affine::scale(s).mul(&so.transform); + // Row-major source → document map: scaling the document side scales its first two rows. + if let Some(h) = &mut so.perspective { + for v in h.iter_mut().take(6) { + *v *= s; + } + } +} + /// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The /// selection scales with it, so a filter previewed on the proxy stays inside the (feathered) /// selection exactly as the full-size result does. @@ -166,6 +185,33 @@ mod tests { assert!(proxy_faithful(&doc)); } + /// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its + /// placement, which must shrink with the canvas, cache and filter mask. + #[test] + fn proxy_scales_smart_object_placement() { + use photocraft_doc::SmartSource; + let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut cache = Surface::new(doc.pixel_format()); + cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]); + let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) }; + let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache)); + let mut mask = LayerMask::hide_all(); + mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]); + sm.filter_mask = Some(mask); + sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]); + doc.layers.push(Layer::new("smart", LayerContent::Smart(sm))); + let p = proxy_document(&doc, 4); + let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") }; + assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] }); + assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12)); + let mask = &sm.filter_mask.as_ref().unwrap().surface; + assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0)); + // The document keeps its full-size placement. + let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") }; + assert_eq!(orig.transform.m[4], 16.0); + } + #[test] fn proxy_scales_the_selection() { let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 778d9cf29a..d8842a24fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask}; use photocraft_geom::Size; @@ -606,6 +606,142 @@ fn a_16_bit_lab_invert_layer_keeps_greys_neutral() { assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}"); } +/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`. +fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document { + let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth); + let mut l = Layer::raster("bg", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]); + d.layers.push(l); + d +} + +/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on +/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast +/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an +/// (unfeathered) mask as linear ink mixes. +#[test] +fn cmyk_ink_adjustments_composite_on_the_documents_inks() { + use photocraft_color::convert::cmyk_to_rgb; + let ink = [0.2, 0.4, 0.6, 0.1]; + let rgb = |i: [f32; 4]| { + let c = cmyk_to_rgb(i); + [c[0], c[1], c[2], 1.0] + }; + for depth in [SampleType::U8, SampleType::U16] { + let mut d = cmyk_doc(ink, depth); + // Stored inks, as the document's surface rounds them. + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert"); + // Half opacity and a half mask mix the inks linearly: a quarter of the way. + d.layers[1].opacity = 0.5; + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]); + d.layers[1].mask = Some(m); + let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed"); + // Brightness/Contrast curves each ink's stored value. + let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true); + d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true })); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast"); + // Flattening keeps the adjusted inks exactly, with no trip through RGB. + let flat = flatten_to_surface(&d, d.pixel_format(), None); + let got = flat.pixel(2, 2); + for c in 0..4 { + let want = adjust::ink_through(&table, stored[c]); + let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 }; + assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]); + } + } +} + +/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep +/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks +/// don't carry or a layer they can't composite. +#[test] +fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() { + let r = Rect::new(0, 0, 4, 4); + let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8); + assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers"); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&d, r, 64).is_some()); + let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }; + d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure))); + assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry"); + d.layers.pop(); + d.layers[1].blend = BlendMode::Multiply; + assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode"); + d.layers[1].blend = BlendMode::Normal; + let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8); + rgb.mode = ColorMode::Rgb; + rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document"); + // Tiles render alike whatever their size. + let whole = render_tiled(&d, d.bounds(), 64); + assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px); +} + +/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the +/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level). +#[test] +fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() { + use photocraft_color::convert::cmyk_to_rgb; + let ranges = photocraft_doc::adjust::HueRange::defaults(); + let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() }; + let balance = + Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true }; + for adj in [hue, balance] { + assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj)); + let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16); + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone()))); + // The RGB adjustment on the inks' complement, black untouched. + let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]); + adjust::apply(&adj, &mut rgb); + let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]]; + assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label()); + let c = cmyk_to_rgb(want); + assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label()); + let flat = flatten_to_surface(&d, d.pixel_format(), None); + let got = flat.pixel(1, 1); + assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label()); + assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label()); + } + // Colorize separates its colour again (black included), so it stays on the screen colour. + let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges }; + assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize)); +} + +/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches +/// the layers below it, and an isolated group of raster layers composites as its children would. +/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the +/// RGB compositor. +#[test] +fn cmyk_ink_compositing_inlines_open_groups() { + use photocraft_color::convert::cmyk_to_rgb; + let r = Rect::new(0, 0, 4, 4); + let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16); + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let c = cmyk_to_rgb(stored.map(|v| 1.0 - v)); + assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below"); + d.layers[1].blend = BlendMode::Normal; + assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment"); + d.layers[1].blend = BlendMode::PassThrough; + d.layers[1].opacity = 0.5; + assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group"); + // An isolated group of raster layers, with an adjustment above it, still composites on the inks. + let mut top = Layer::raster("top", d.pixel_format()); + top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]); + d.layers[1] = Layer::group("raster", vec![top]); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&d, r, 64).is_some()); + let c = cmyk_to_rgb(stored.map(|v| 1.0 - v)); + assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer"); +} + #[test] fn cmyk_document_renders_via_rgb() { let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8); @@ -1728,6 +1864,43 @@ fn modern_contrast_matches_photoshop() { } } +/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes +/// raising it by as much, and odd input never panics. +#[test] +fn modern_brightness_is_monotone_and_lowering_inverts_raising() { + for b in (-150..=150).step_by(5).map(|b| b as f32) { + let mut prev = 0.0f32; + for k in 0..=1020 { + let v = adjust::modern_brightness(k as f32 / 1020.0, b); + assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}"); + prev = v; + } + assert!( + (adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5, + "brightness {b} pins black and white" + ); + for k in 0..=50 { + let x = k as f32 / 50.0; + let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b); + assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}"); + } + } + for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] { + let _ = adjust::modern_brightness(v, 40.0); + let _ = adjust::modern_brightness(0.5, v); + } + assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096); + // The table agrees with the curves it samples, lowered (inverted in one pass) or raised. + for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] { + let t = adjust::modern_lut(b, c); + for k in (0..4096).step_by(37) { + let x = k as f32 / 4095.0; + let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c); + assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]); + } + } +} + #[test] fn modern_brightness_matches_photoshop() { let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])]; @@ -1736,8 +1909,48 @@ fn modern_brightness_matches_photoshop() { for (i, &x) in xs.iter().enumerate() { let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0; let err = (got - out[i] as f32).abs(); - // The brightness roll-off is a spline; our fit is close but approximate at the top. - assert!(err <= 6.0, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]); + // Within about a level of Photoshop's (rounded) output everywhere. + assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]); + } + } + // Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8 + // brightness-contrast.psd): what the oracle's levels become. + let oracle = [ + (8, 7), + (24, 23), + (48, 49), + (72, 78), + (88, 99), + (104, 120), + (128, 154), + (144, 174), + (160, 192), + (176, 208), + (192, 222), + (200, 228), + (216, 239), + (232, 247), + (240, 250), + (248, 253), + ]; + for (x, want) in oracle { + let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0; + assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}"); + } + // psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness + // each applies, read back through its (exact) contrast curve. + // (brightness, tolerance in levels, [(input level, Photoshop's output level)]) + type Measured = (f32, f32, &'static [(u32, f32)]); + let measured: [Measured; 4] = [ + (-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]), + (-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]), + (23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]), + (150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]), + ]; + for (b, tol, points) in measured { + for &(x, want) in points { + let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0; + assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}"); } } // Endpoints and the zero slider are exact. @@ -1748,7 +1961,7 @@ fn modern_brightness_matches_photoshop() { } // Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our -// `levels()` matches the real app, including the gamma>1 soft shadow toe (initial slope 2^gamma). +// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma). #[test] fn levels_matches_photoshop() { let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 }; @@ -1764,8 +1977,8 @@ fn levels_matches_photoshop() { let want = ((x as f32 / 255.0).powi(2) * 255.0).round(); assert_eq!(got, want, "levels gamma 0.5 x={x}"); } - // Endpoints pinned; identity is identity. (The gamma>1 toe's soft-min leaves white within a - // few parts in 1e5 of 1.0 — invisible at any bit depth; exact at 8-bit.) + // Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white + // stays exactly 1.0.) assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0); assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0); assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4); @@ -1806,6 +2019,86 @@ fn levels_work_on_whole_levels() { } } +// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at +// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from: +// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma +// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow +// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54). +// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245. +// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off). +#[test] +fn levels_steps_match_photoshop() { + let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel { + in_black: ib / 255.0, + in_white: iw / 255.0, + gamma, + out_black: ob / 255.0, + out_white: ow / 255.0, + }; + let id = LevelsChannel::default(); + let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels { + master, + per_channel, + space: Default::default(), + black: LevelsChannel::default(), + }; + let run = |adj: &Adjustment, xs: &[u8]| { + let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect(); + let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px }; + adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8)); + b.px + }; + let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]); + let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200]; + let want: [[u8; 21]; 3] = [ + [48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174], + [51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244], + [48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164], + ]; + for (k, p) in run(&rgb, &xs).iter().enumerate() { + for c in 0..3 { + assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]); + } + } + let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]); + let oracles: [(Adjustment, &[u8], &[u8]); 2] = [ + ( + single(ch(15.0, 230.0, 1.3, 10.0, 245.0)), + &[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251], + &[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245], + ), + ( + single(ch(44.0, 214.0, 1.78, 0.0, 255.0)), + &[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214], + &[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255], + ), + ]; + for (adj, xs, want) in &oracles { + for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) { + assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}"); + } + } +} + +// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a +// jump where it meets the power curve. +#[test] +fn levels_gamma_toe_is_monotone_for_any_gamma() { + for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] { + let mut last = 0.0f32; + for k in 0..=4096 { + let u = k as f32 / 4096.0; + let t = adjust::levels_gamma(u, g); + assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}"); + assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}"); + // Never steeper than the toe's initial slope 2^g. + assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}"); + last = t; + } + assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white"); + } +} + // The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a // 32-bit document neither range clips and the gamma is a plain power curve mirrored below black: // 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip. @@ -2324,9 +2617,10 @@ fn photo_filter_matches_photoshop() { } } // The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is - // looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply). + // looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's + // results there are unclipped: above 1 and below 0. let warming32 = [0.966_445_6, 0.761_806_3, 0.0]; - for (v, ps) in [([0.2159, 0.2159, 0.2159], [66.0, 53.0, 40.0]), ([1.0, 1.0, 0.0], [255.0, 251.0, 0.0]), ([1.0, 0.0, 0.0], [255.0, 0.0, 0.0])] { + for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] { let got = filter(warming32, SampleType::F32, v); assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}"); } @@ -2530,15 +2824,95 @@ fn lab_documents_invert_and_posterize_l_a_and_b() { let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v)); assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}"); // Posterize, three levels: the same steps on L, a and b (here all land on the middle - // step: L ≈ 50 and a, b ≈ -1, a neutral grey). + // step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey). let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth); - let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3)); + let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth))); let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]; assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}"); near3(post, want, 1.0, &format!("{depth:?} posterize")); } } +/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces +/// store them), with `adjustments` above it. +fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec) -> Document { + let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth); + let fmt = doc.pixel_format(); + let mut layer = Layer::raster("bg", fmt); + if let Some(s) = layer.surface_mut() { + s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]); + } + doc.layers.push(layer); + doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a)))); + doc +} + +/// Display RGB of stored Lab `n`. +fn lab_display(n: [f32; 3]) -> [f32; 3] { + photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) +} + +// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips, +// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and +// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives +// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does. +#[test] +fn lab_adjustments_work_on_the_stored_values() { + let byte = |v: u8| f32::from(v) / 255.0; + let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]); + let p = px(&doc, 1, 1); + let want = lab_display([byte(102), byte(37), byte(217)]); + near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple"); + // Through clipped display RGB the result is another colour. + let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)])); + let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]); + assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}"); + // Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway. + let ch = LevelsChannel { gamma: 2.0, ..Default::default() }; + let levels = Adjustment::Levels { + master: LevelsChannel::default(), + per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()], + space: ToneSpace::Lab, + black: LevelsChannel::default(), + }; + let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]); + if let Some(l) = doc.layers.last_mut() { + l.opacity = 0.5; + } + let p = px(&doc, 1, 1); + let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5; + near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); +} + +// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB +// compositor clipped them on the way through display RGB). +#[test] +fn lab_flatten_keeps_the_stored_values() { + let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0]; + for depth in [SampleType::U8, SampleType::U16] { + let doc = lab_stack(lab, depth, vec![]); + let s = flatten_to_surface(&doc, doc.pixel_format(), None); + let got = s.pixel(2, 2); + assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}"); + } +} + +// Anything the stored values can't carry exactly keeps the RGB compositor. +#[test] +fn lab_stacks_with_other_layers_keep_the_rgb_compositor() { + let lab = [0.6, 0.5, 0.5]; + assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts())); + let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() }; + assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none()); + let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]); + if let Some(l) = multiply.layers.last_mut() { + l.blend = photocraft_color::blend::BlendMode::Multiply; + } + assert!(native_lab::LabStack::new(&multiply).is_none()); + let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5)); + assert!(native_lab::LabStack::new(&rgb).is_none()); +} + #[test] fn lab_documents_brightness_contrast_moves_lightness_only() { let lab = photocraft_color::convert::srgb_to_lab(MUTED); @@ -2594,6 +2968,75 @@ fn lab_documents_gradient_map_follows_lightness() { assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } +// ---------- Effect-map identity is computed once per layer per render (#4437) ---------- + +/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled +/// child, and a layer whose mask hides its effects (rendered from a temporary copy). +fn identity_doc(depth: SampleType) -> Document { + let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE); + let fmt = d.pixel_format(); + let mut sq = Layer::raster("sq", fmt); + sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))]; + d.layers.push(sq); + let mut child = Layer::raster("child", fmt); + child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]); + child.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + let mut group = Layer::group("g", vec![child]); + group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))]; + d.layers.push(group); + let mut hidden = Layer::raster("hidden", fmt); + hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]); + hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + hidden.mask = Some(LayerMask::reveal_all()); + hidden.advanced.layer_mask_hides_effects = true; + d.layers.push(hidden); + d +} + +#[test] +fn memoised_effect_identity_leaves_every_render_unchanged() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = identity_doc(depth); + let b = d.bounds(); + // One tile for the whole canvas never goes through `prepare_effects`; small tiles do. + purge_effect_cache(); + let whole = render_tiled(&d, b, 4096); + purge_effect_cache(); + let tiled = render_tiled(&d, b, 16); + let warm = render_tiled(&d, b, 24); + assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile"); + assert!(whole.px == warm.px, "{depth:?}: warm differs from cold"); + assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw"); + // Edits still invalidate: a changed layer must not keep its old maps. + let mut e = d.clone(); + e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))]; + let edited = render_tiled(&e, b, 16); + purge_effect_cache(); + assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit"); + assert!(edited.px != whole.px, "{depth:?}: the edit should show"); + } +} + +#[test] +fn effect_identity_is_built_once_per_layer_not_per_tile() { + let mut d = identity_doc(SampleType::U8); + // Leave out the temporary-copy layer: it is rebuilt per call by design. + d.layers.pop(); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&d, &patterns); + let b = d.bounds(); + let tiles = 6 * 6; + let first = render_tiled_with(&d, b, 16, &cx); + let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed); + // sq, the group and its child: three effect layers, however many tiles. + assert_eq!(built, 3, "identity built {built} times over {tiles} tiles"); + // A second render with the same context finds all of them memoised. + let second = render_tiled_with(&d, b, 16, &cx); + assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3); + assert!(first.px == second.px); +} + #[test] fn render_refuses_rects_beyond_the_pixel_cap() { let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); @@ -2668,3 +3111,57 @@ fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_eff assert!(!b_fx.is_empty()); assert!(b_fx.contains_rect(&b)); } + +#[test] +#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"] +fn modern_lut_timing() { + let t = std::time::Instant::now(); + for _ in 0..100 { + std::hint::black_box(adjust::modern_lut(-50.0, 30.0)); + } + println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100); +} + +#[test] +#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"] +fn cmyk_ink_render_timing() { + // 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the + // RGB compositor (Exposure, which the inks don't carry). + let (w, h) = (6000, 4000); + let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8); + let mut l = Layer::raster("bg", d.pixel_format()); + let data: Vec = (0..(w * h) as usize) + .flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0]) + .collect(); + l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data); + d.layers.push(l); + for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] { + d.layers.truncate(1); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone()))); + let _ = render(&d, Rect::new(0, 0, 512, 512)); + let t = std::time::Instant::now(); + let b = render(&d, d.bounds()); + println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len()); + } +} + +// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is +// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no +// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment). +#[test] +fn a_pass_through_clipping_base_keeps_its_adjustments_inside() { + let base = || { + let mut d = doc_white(4, 4); + let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]); + d.layers.push(group); + d + }; + // Unclipped: the invert reaches the white background. + assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1)); + // With a layer clipped to the group: the background stays white. + let mut d = base(); + let mut clip = Layer::raster("clipped", PixelFormat::RGBA8); + clip.clipped = true; + d.layers.push(clip); + assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1)); +} diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3b3ffd1f01..d881a6112b 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -45,11 +45,17 @@ pub struct Action { /// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`). #[serde(default, skip_serializing_if = "Option::is_none")] pub set: Option, + /// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`]. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub color: String, } +/// Button Mode colours, in Photoshop's order. Empty is no colour. +pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray"]; + impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { - Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None } + Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() } } /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. @@ -235,7 +241,7 @@ fn get(s: &mut Session, p: &Value) -> Result { let idx = resolve(&s.actions.list, p, "actions.get")?; let action = &s.actions.list[idx]; let steps: Vec = action.steps.iter().map(|(id, params)| json!([id, params])).collect(); - Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set})) + Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color})) } fn record(s: &mut Session, p: &Value) -> Result { @@ -553,6 +559,25 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "index": idx})) } +fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.setColor"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + let Some(color) = p.get("color").and_then(Value::as_str) else { + return Err(bad(cmd, "needs \"color\"")); + }; + if !BUTTON_COLORS.contains(&color) { + return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, or gray")); + } + let Some(action) = s.actions.list.get_mut(idx) else { + return Err(bad(cmd, "no such action")); + }; + action.color = color.to_string(); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "color": color})) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -580,7 +605,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##, + params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is the Button Mode swatch, or empty)"##, enabled: always, run: get, journal: false, @@ -645,6 +670,16 @@ pub fn specs() -> Vec { run: rename, journal: false, }, + CommandSpec { + id: "actions.setColor", + label: "Set Action Color", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"} → {action, color}. The Button Mode colour. Empty clears it."##, + enabled: always, + run: set_color, + journal: false, + }, ] } @@ -957,6 +992,109 @@ mod tests { } } + #[test] + fn recorded_layer_selection_replays_by_name_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + let original = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap(); + s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap(); + // The target name must be captured now, not when recording stops. + s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"})); + // Persisted actions must work in an entirely different session too. + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + let target = replay.active().unwrap().active_layer.unwrap(); + assert_ne!(original, target); + replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 3); + assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched"); + let active = replay.active().unwrap().active_layer.unwrap(); + assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy"); + } + } + + #[test] + fn named_selection_preserves_modes_and_duplicate_occurrences() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.setProps", json!({"name":"Photo"})).unwrap(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + // A duplicate nested in a group must be found even when that group is collapsed. + let second = s + .edit("child", |doc, active| { + let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format()); + let id = child.id; + if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content { + group.children.push(child); + group.expanded = false; + } + Ok(id) + }) + .unwrap(); + s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap(); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0})); + assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"})); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + let r = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap(); + assert!(s.active().unwrap().selected_layers().contains(&second)); + // Main's imported .atn steps use a string-valued `layer` and choose the topmost match. + s.execute("layer.select", json!({"layer":"Photo"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + // Removing an unselected layer does not add it; removing the last target keeps it. + s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + for p in [ + json!({"name":"absent"}), + json!({"name":"Photo"}), + json!({"name":1}), + json!({"name":"Photo","occurrence":-1}), + json!({"name":"Photo","occurrence":1.5}), + json!({"name":"Photo","occurrence":99}), + json!({"name":"Photo","layer":first.0}), + json!({"layer":first.0,"occurrence":0}), + ] { + let before = s.active().unwrap().selected_layers(); + assert!(s.execute("layer.select", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().selected_layers(), before); + } + s.actions.list.push(Action { + name: "Missing".into(), + steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let before = s.active().unwrap().doc.walk().len(); + let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().unwrap().contains("absent")); + assert_eq!(s.active().unwrap().doc.walk().len(), before); + } + #[test] fn play_stops_at_the_failing_step_and_reports_it() { let mut s = Session::new(); @@ -1308,4 +1446,24 @@ mod tests { let first = &s.documents()[0].doc; assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop"); } + + #[test] + fn button_color_is_one_of_the_photoshop_swatches() { + let mut s = Session::new(); + s.execute("actions.record", json!({"name": "Print"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let set = s.execute("actions.setColor", json!({"action": "Print", "color": "blue"})).unwrap(); + assert_eq!(set["color"], "blue"); + assert_eq!(s.actions.list[0].color, "blue"); + assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["color"], "blue"); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + assert!(saved.contains("blue")); + s.execute("actions.setColor", json!({"action": 0, "color": ""})).unwrap(); + assert!(s.actions.list[0].color.is_empty()); + assert!(!serde_json::to_string(&s.actions.list).unwrap().contains("color"), "an empty colour is omitted"); + for bad in [json!({"action": 0, "color": "pink"}), json!({"action": 0}), json!({"action": 4, "color": "red"}), json!({"color": "red"})] { + assert!(s.execute("actions.setColor", bad).is_err()); + } + assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing"); + } } diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f614732de..80e97b9f34 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -527,6 +527,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert_eq!(rgba(&s, 23, 3), pixels); } +/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth. +#[test] +fn curves_apply_to_non_colour_targets_at_every_depth() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for depth in DEPTHS { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, "rgb"); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"}) + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"}) + } + }; + let edit_target = p.get("target").cloned().unwrap_or(json!(null)); + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap(); + surf.fill_rect(area, &[0.5]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| { + let d = s.active().unwrap(); + let layer = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let t = match target { + "quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0), + _ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + }; + (rgba(s, 8, 8), t) + }; + let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len()); + s.execute("image.adjustments.curves", p).unwrap(); + let (pixels_after, after) = read(&s); + let case = format!("{target} at {depth}"); + assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put"); + assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); + s.undo(); + assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target"); + } + } +} + +/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's +/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab +/// ignores the composite) or an ink-separated result (CMYK curves the inks). +#[test] +fn curves_on_grey_targets_ignore_the_document_colour_mode() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for mode in ["lab", "cmyk"] { + for depth in [8u64, 16] { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, mode); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!("mask") + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"channel": 0}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!("quickMask") + } + }; + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap(); + surf.fill_rect(area, &[0.25]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| match target { + "quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0), + _ => mask_at(s, 8, 8), + }; + let before = read(&s); + let pixels = rgba(&s, 8, 8); + s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap(); + let after = read(&s); + let case = format!("{mode} {depth}-bit {target}"); + assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})"); + assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})"); + assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put"); + } + } + } +} + #[test] fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 64af849e59..6ad25951db 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); + // An inner command may select a layer that disappears when the compound rolls back. + // Selection, channel targeting and coalescing must roll back with the document (#4975). + let anchor = st.layer_anchor; + let channel_view = st.channel_view.clone(); + let coalesce = st.coalesce.clone(); + let was_clean = !st.is_dirty(); let journal = s.journal.len(); // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. @@ -187,7 +193,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } let st = s.active_mut().ok_or(EngineError::NoDocument)?; st.history = history; - match r { + let result = match r { Ok(v) => { // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); @@ -198,12 +204,26 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } Err(e) => { st.doc = before; + st.active_layer = prior.active; + st.selected_layers = prior.selected; + st.layer_anchor = anchor; + st.channel_view = channel_view; + st.coalesce = coalesce; st.color_samplers = samplers; st.revision += 1; + // A failed compound does not make an otherwise untouched document dirty. + if was_clean { + st.saved_revision = st.revision; + } st.last_damage = None; Err(e) } + }; + if result.is_err() { + // The app-wide tool colours/mask mode follow the restored channel target. + s.sync_tools_mask_target(); } + result } /// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock). diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index e5272d558d..124f9abf1e 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc { s.active().unwrap().doc.clone() } +#[test] +fn failed_compound_restores_layer_target_history_and_journal() { + let mut s = session(80, 60, 8); + let background = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap(); + let selected = s.active().unwrap().active_layer.unwrap(); + { + let st = s.active_mut().unwrap(); + st.selected_layers = vec![background, selected]; + st.layer_anchor = Some(background); + st.coalesce = Some("previous-gesture".into()); + } + + let st = s.active().unwrap(); + let prior_doc = st.doc.clone(); + let prior_history = st.history.past_len(); + let prior_channel_view = st.channel_view.clone(); + let prior_mask_targeted = s.tools.mask_targeted; + let prior_journal = s.journal.len(); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + // Mirrors a scale-bar creation followed by a failed type.create: the + // inner command selects a new layer, which rollback must remove. + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask; + s.sync_tools_mask_target(); + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + + assert!(error.to_string().contains("synthetic second step failed")); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored"); + assert_eq!(st.history.past_len(), prior_history, "no undo step remains"); + assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled"); + assert_eq!(st.active_layer, Some(selected)); + assert_eq!(st.selected_layers, vec![background, selected]); + assert_eq!(st.layer_anchor, Some(background)); + assert_eq!(st.channel_view, prior_channel_view); + assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel"); + assert_eq!(st.coalesce.as_deref(), Some("previous-gesture")); + assert!(st.doc.layer(selected).is_some()); + + // Subsequent ordinary commands target the original layer, not the + // nonexistent scale bar. + s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap(); + assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible); +} + +#[test] +fn failed_compound_preserves_a_clean_document() { + let mut s = session(80, 60, 8); + assert!(!s.active().unwrap().is_dirty()); + let prior_doc = d(&s); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + assert!(error.to_string().contains("synthetic second step failed")); + assert!(Arc::ptr_eq(&prior_doc, &d(&s))); + assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving"); +} + #[test] fn record_measurements_refuses_an_area_too_large_to_measure() { // #933: with no selection the whole document is measured, and `width * height` overflowed diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..ebeda48687 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..871300372c 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -448,6 +454,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some( + desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])), + ), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -457,8 +474,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..b76f8c836d 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Result // A library preset's tips stay on disk until a stroke uses them (#1843). s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; if let Some(v) = num(p, "size") { - b.size = v.max(0.5); + b.resize_with_dual_brush(v.max(0.5)); } if let Some(v) = num(p, "hardness") { b.hardness = v.clamp(0.0, 1.0); @@ -868,11 +873,24 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { v[y * w + x] = ((1.0 - l) * c[3] * sv[(y * w + x) * sc]).clamp(0.0, 1.0); } } + let (brush, tw, th) = sampled_brush(cmd, &v, w, h)?; + upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); + s.tools.brush = brush; + s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); + Ok(json!({ "name": name, "width": tw, "height": th })) +} + +/// A sampled-tip brush from a paint-amount map (`w`×`h`, row-major, 0 = no paint, 1 = full): +/// cropped to the painted extent, at most [`MAX_TIP`] px a side. Shared by Define Brush Preset +/// and importing an image file as a brush (`brush.presets.importImage`). +pub(crate) fn sampled_brush(cmd: &str, v: &[f32], w: usize, h: usize) -> Result<(BrushSettings, u32, u32)> { + let at = |x: usize, y: usize| v.get(y * w + x).copied().unwrap_or(0.0); // Crop to the painted extent. let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); for y in 0..h { for x in 0..w { - if v[y * w + x] > 1e-4 { + if at(x, y) > 1e-4 { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x + 1); @@ -881,23 +899,19 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { } } if x1 <= x0 || y1 <= y0 { - return Err(bad(cmd, "the selected pixels contain no paint (all white or transparent)")); + return Err(bad(cmd, "the pixels contain no paint (all white or transparent)")); } let (tw, th) = ((x1 - x0) as u32, (y1 - y0) as u32); if tw.max(th) > MAX_TIP { - return Err(bad(cmd, format!("selection too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)"))); + return Err(bad(cmd, format!("too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)"))); } let mut data = Vec::with_capacity((tw * th) as usize); for y in y0..y1 { - data.extend_from_slice(&v[y * w + x0..y * w + x1]); + data.extend((x0..x1).map(|x| at(x, y))); } let tip = GrayTile::from_f32(tw, th, &data); let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() }; - upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); - s.tools.brush = brush; - s.tools.current_preset = Some(name.clone()); - s.tools.brush_changed(); - Ok(json!({ "name": name, "width": tw, "height": th })) + Ok((brush, tw, th)) } pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..eaf69281bc 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -349,6 +349,38 @@ fn set_brush_merges_fields() { assert_eq!(s.tools.brush.texture.pattern, chalk_pattern); } +#[test] +fn resizing_a_brush_resizes_its_enabled_secondary_tip() { + let mut s = session(64, 64); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0)); + + s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0)); + + // Editing both diameters in the same patch is an explicit secondary-tip size. + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0)); + s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 9.0); + + // The stroke-level size override must use the same proportional dimensions. + let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap(); + assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0)); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); +} + +#[test] +fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0)); +} + #[test] fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() { let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0); @@ -910,6 +942,23 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() { assert!(r.get("fromLive").is_none()); } +#[test] +fn set_brush_takes_a_per_dynamic_curve_and_a_patch_resets_it() { + let mut s = session(20, 20); + let curve = json!([[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + s.execute("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "curve": curve}}})).unwrap(); + let with = s.tools.brush.clone(); + assert_eq!(with.shape_dynamics.size.curve.points(), vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + assert!(with.transfer.opacity.curve.is_linear(), "other dynamics keep their own curve"); + // The panel's diff back to linear is a real patch, and applying it restores the linear curve. + let mut linear = with.clone(); + linear.shape_dynamics.size.curve = photocraft_paint::ResponseCurve::LINEAR; + let patch = brush_patch(&with, &linear); + assert_eq!(patch["shapeDynamics"]["size"]["curve"], json!([[0.0, 0.0], [1.0, 1.0]])); + s.execute("tools.setBrush", patch).unwrap(); + assert!(s.tools.brush.shape_dynamics.size.curve.is_linear()); +} + #[test] fn last_brush_is_remembered_when_the_revision_moves_only() { let mut s = session(8, 8); diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index fea1048235..86a49b0be6 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -96,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } if !area.is_empty() { - let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?; + let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?; src.read_rgba_into(area, &mut rgba); - let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = surf.read_region(area); + let weight = limit + .map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1)))) + .transpose()?; + let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?; for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); @@ -107,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: *c += (v - *c) * w.clamp(0.0, 1.0); } } - surf.write_region(area, &px); + surf.try_write_region(area, &px)?; surf.prune(); } doc.selection = None; diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index be7e51dfd7..3d89c8f647 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -164,6 +164,25 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() { } } +#[test] +fn channel_paste_allocation_failure_is_transactional() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = paste(&mut s, &json!({}), 1, true, None, "Paste"); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap(); + assert!(s.undo()); + assert_eq!(*s.active().unwrap().doc, *before); + } +} + #[test] fn channel_copy_allocation_failure_keeps_clipboard_and_document() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 00f3ec4e31..6dac6f2a69 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -182,6 +182,87 @@ pub fn composite_name(mode: ColorMode) -> &'static str { } } +/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a +/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IsolatedCurveChannel { + /// Channels-panel name, including a custom alpha name or "{layer} Mask". + pub name: String, + /// Curves param key: empty = composite `points`; otherwise `red`/`green`/… + pub key: &'static str, + /// Index into the tone-space histograms (composite = 0 for RGB/CMYK). + pub hist: usize, + /// Colour components keep their curve tint; masks and alphas do not. + pub tint: bool, + /// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom + /// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335). + pub translate: bool, + /// The gray plane this target edits, whatever the document's colour mode; its surface is + /// what the dialog's histogram must describe (#5335). None for colour components. + pub gray: Option, +} + +/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels +/// even in Lab and CMYK documents (#5335). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraySurface { + /// The active layer's layer mask. + LayerMask, + /// A saved channel, as an index into [`Document::channels`]. + Alpha(usize), + /// The Quick Mask. + QuickMask, +} + +/// Isolated Curves channel for the active document, matching [`inject_target`]. +pub fn isolated_curve_channel(st: &DocState) -> Option { + isolated_curve_channel_for(st, false) +} + +/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask +/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask +/// must give the same Curves dropdown (#5335). +pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option { + let doc = &st.doc; + let mode = edit_mode(doc); + match st.channel_view.target { + ChannelTarget::Color(k) => { + let names = color_names(mode); + let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); + let name = names.get(k)?.to_string(); + let key = *keys.get(k)?; + let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) }; + Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None }) + } + ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel { + name: c.name.clone(), + key: "", + hist: 0, + tint: false, + translate: false, + gray: Some(GraySurface::Alpha(i)), + }), + ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel { + name: "Quick Mask".into(), + key: "", + hist: 0, + tint: false, + translate: true, + gray: Some(GraySurface::QuickMask), + }), + ChannelTarget::LayerMask => layer_mask_curve_channel(st), + ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), + _ => None, + } +} + +fn layer_mask_curve_channel(st: &DocState) -> Option { + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + layer.mask.as_ref()?; + let name = format!("{} Mask", layer.name); + Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) +} + fn edit_mode(doc: &Document) -> ColorMode { doc.pixel_format().mode } diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 1d721ee5c5..d63dc5fd0d 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30)); } } + +/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel. +#[test] +fn isolated_curve_channel_matches_the_channels_panel() { + let mut s = session(); + assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel"); + s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let green = isolated_curve_channel(s.active().unwrap()).expect("green"); + assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true)); + assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language"); + + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask = isolated_curve_channel(s.active().unwrap()).expect("mask"); + assert_eq!(mask.key, ""); + assert!(!mask.tint); + assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name)); + + s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha"); + assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false)); + assert!(!alpha.translate, "a custom channel name stays exactly as entered"); + assert_eq!(alpha.gray, Some(GraySurface::Alpha(0))); + + let mut qm = session(); + qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask"); + assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false)); + assert_eq!(row.gray, Some(GraySurface::QuickMask)); +} + +/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as +/// selecting it in the Channels panel; the Quick Mask keeps priority. +#[test] +fn layers_panel_mask_target_isolates_the_curve_channel() { + let mut s = session(); + assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted"); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel"); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel"); + assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown"); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); + assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a954eca7e2..7ddad80737 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use crate::{EngineError, Result, Session, inspect, pixels}; @@ -143,10 +143,32 @@ pub fn command_specs() -> &'static [CommandSpec] { pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option) -> Value { let needs_document = s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p))); - json!({ + let mut entry = json!({ "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document, - }) + }); + if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) { + o.insert("keywords".into(), json!(k)); + } + entry +} + +/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and +/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column +/// Marquee, which an agent searches for as "ellipse" or "marquee". +pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] = + &[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")]; + +/// [`SEARCH_KEYWORDS`] of command `id`, if it has any. +pub fn search_keywords(id: &str) -> Option<&'static str> { + SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words) +} + +/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`, +/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case. +pub fn list_entry_matches(entry: &Value, needle: &str) -> bool { + let needle = needle.to_lowercase(); + ["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle))) } pub fn find(id: &str) -> Option<&'static CommandSpec> { @@ -392,6 +414,31 @@ fn doc_mode(s: &Session) -> ColorMode { s.active().map_or(ColorMode::Rgb, |d| d.doc.mode) } +/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask, +/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and +/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK +/// (where the curve runs on the inks) (#5335). +fn adjust_mode(s: &Session, p: &Value) -> ColorMode { + if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) } +} + +/// Tone params for a gray plane target: the document-space channel records are dropped, because +/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335). +fn gray_tone_params(p: &Value) -> Value { + match p { + Value::Object(m) => { + let mut o = Map::new(); + for (k, v) in m { + if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") { + o.insert(k.clone(), v.clone()); + } + } + Value::Object(o) + } + other => other.clone(), + } +} + // ---------- the table ---------- #[allow(clippy::too_many_lines)] @@ -668,7 +715,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), @@ -1247,7 +1294,16 @@ fn build() -> Vec { }); let adj = match eyedropped { Some(adj) => adj, - None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?, + None => { + // A gray plane target keeps the neutral tone space even over params + // carrying Lab or CMYK records (#5335). + let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { + if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() } + } else { + p.clone() + }; + crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))? + } }; let label = adj.label().to_string(); // A smart object records the adjustment as a smart filter (Photoshop), which @@ -1261,6 +1317,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::layer_list_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); @@ -1334,6 +1391,7 @@ fn build() -> Vec { v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); v.extend(crate::document_preset_cmds::specs()); + v.extend(crate::export_preset_cmds::specs()); v.extend(crate::render_cmds::specs()); v.extend(crate::slice_cmds::specs()); v.extend(crate::web_cmds::specs()); @@ -1550,7 +1608,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result { solo.clipped = false; let mut tmp = doc.clone(); tmp.layers = vec![solo]; - let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None))); + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt))); merged.id = group.id; merged.opacity = group.opacity; merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs new file mode 100644 index 0000000000..6ef0698cea --- /dev/null +++ b/crates/engine/src/crop_fill.rs @@ -0,0 +1,213 @@ +//! The Crop tool's Fill: Content-Aware Fill (#3823). After a rotated crop or one that extends past +//! the canvas, the area the original image doesn't cover (the corners a rotation uncovers, the +//! strips an extension adds) is filled from its surroundings instead of holding the background +//! colour or transparency. +//! +//! Which layer: the Background when the document has one (the layer the crop filled with the +//! background colour), otherwise the active pixel layer (where the crop left transparency). Other +//! layers keep their transparency there, as after a plain crop. Each connected piece of the +//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling +//! window around that piece, and only pixels the image covers are sampled. + +use photocraft_doc::{Document, LayerId}; +use photocraft_geom::Rect; +use rayon::prelude::*; + +use crate::{EngineError, Result}; + +/// Seed for the patch search, so a crop fills the same way every time. +const SEED: u64 = 1; + +/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap. +const MAX_PIXELS: u64 = 400_000_000; + +/// Where the cropped canvas came from in the original document. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Coverage { + /// Cropped canvas → original document: `[a, b, c, d, e, f]` with + /// `x = a·u + b·v + e`, `y = c·u + d·v + f`. + pub map: [f64; 6], + /// The original canvas size (px). + pub width: f64, + pub height: f64, + /// Whether the map rotates (its resampled edge is antialiased against the background colour). + pub rotated: bool, +} + +impl Coverage { + /// An unrotated crop to `r` (original document px). + pub fn crop(r: Rect, width: u32, height: u32) -> Self { + Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false } + } + + /// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside + /// the original canvas by at least half the pixel's projected size (exact for an unrotated + /// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling + /// blended with the background colour. + fn covered(&self, u: i32, v: i32, margin: f64) -> bool { + let [a, b, c, d, e, f] = self.map; + let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5); + let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f); + x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin + } + + fn margin(&self) -> f64 { + let [a, b, ..] = self.map; + 0.5 * (a.abs() + b.abs()) + if self.rotated { 1.0 } else { 0.0 } + } +} + +/// Labels of the uncovered pixels: 0 covered, `UNLABELLED` uncovered before labelling, else the +/// component number (1-based). +const UNLABELLED: u32 = u32::MAX; + +/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the +/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the +/// caller's history step. +pub(crate) fn fill_uncovered(doc: &mut Document, active: Option, cov: &Coverage) -> Result<()> { + let canvas = doc.bounds(); + let (w, h) = (canvas.width(), canvas.height()); + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(EngineError::Other(format!("the {w} x {h} crop is too large for Content-Aware fill"))); + } + let (w, h) = (w as usize, h as usize); + let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?; + if len == 0 { + return Ok(()); + } + let margin = cov.margin(); + let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?; + labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, l) in row.iter_mut().enumerate() { + if !cov.covered(x as i32, y as i32, margin) { + *l = UNLABELLED; + } + } + }); + if !labels.contains(&UNLABELLED) { + return Ok(()); + } + if !labels.contains(&0) { + return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into())); + } + let id = target_layer(doc, active)?; + let pieces = label_components(&mut labels, w, h)?; + let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; + let label = |x: i32, y: i32| -> u32 { + if x < 0 || y < 0 { + return 0; + } + (y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0) + }; + let n = surf.format().channels().max(1); + let filled: Vec<(Rect, Vec)> = pieces + .par_iter() + .enumerate() + .map(|(k, bounds)| -> Result<(Rect, Vec)> { + let k = k as u32 + 1; + let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?; + // The piece's bounds, its pixels from the fill and the rest as they were. + let mut out = surf.read_region(*bounds); + let ww = window.width() as usize; + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + if label(x, y) != k || !window.contains(x, y) { + continue; + } + let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n; + let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n; + if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) { + d.copy_from_slice(s); + } + } + } + Ok((*bounds, out)) + }) + .collect::>()?; + let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; + for (r, px) in filled { + surf.write_region(r, &px); + } + Ok(()) +} + +/// The Background, else the active pixel layer. +fn target_layer(doc: &Document, active: Option) -> Result { + if let Some(bg) = doc.background() { + return Ok(bg.id); + } + active + .filter(|id| doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_)))) + .ok_or_else(|| EngineError::Other("Content-Aware crop fill needs a Background or an active pixel layer".into())) +} + +/// Numbers the 8-connected pieces of `UNLABELLED` pixels 1, 2, … and returns each one's bounds. +fn label_components(labels: &mut [u32], w: usize, h: usize) -> Result> { + let mut pieces: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + for start in 0..labels.len() { + if labels.get(start) != Some(&UNLABELLED) { + continue; + } + let k = u32::try_from(pieces.len() + 1).ok().filter(|k| *k < UNLABELLED).ok_or_else(|| EngineError::Other("too many uncovered areas".into()))?; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + if let Some(l) = labels.get_mut(start) { + *l = k; + } + stack.push(start); + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + for dy in -1i64..=1 { + for dx in -1i64..=1 { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let j = ny as usize * w + nx as usize; + if let Some(l) = labels.get_mut(j) + && *l == UNLABELLED + { + *l = k; + stack.push(j); + } + } + } + } + pieces.push(Rect::new(x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1)); + } + Ok(pieces) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn components_are_8_connected_with_their_bounds() { + let (w, h) = (6, 4); + let u = UNLABELLED; + #[rustfmt::skip] + let mut l = vec![ + u, 0, 0, 0, 0, u, + 0, u, 0, 0, 0, u, + 0, 0, 0, 0, 0, 0, + 0, 0, 0, u, u, 0, + ]; + let pieces = label_components(&mut l, w, h).unwrap(); + assert_eq!(pieces, vec![Rect::new(0, 0, 2, 2), Rect::new(5, 0, 6, 2), Rect::new(3, 3, 5, 4)]); + assert_eq!(l[7], 1); + assert_eq!(l[11], 2); + assert_eq!(l[22], 3); + } + + #[test] + fn unrotated_coverage_is_exact() { + let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4); + let m = c.margin(); + assert!(!c.covered(1, 0, m), "x = -1"); + assert!(c.covered(2, 0, m), "x = 0"); + assert!(c.covered(6, 0, m), "x = 4"); + assert!(!c.covered(7, 0, m), "x = 5"); + } +} diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 20ef23a41b..dcb89aa980 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -835,6 +835,44 @@ fn guide_color(p: &Value) -> Result> { ) } +/// Edit Selected Guides (the guide context menu): change one guide's orientation, position and +/// colour in one history step. A guide that keeps its orientation keeps its index; one that turns +/// goes to the end of the other list. Without `color` it keeps its own colour, or none. +fn edit_guide(s: &mut Session, p: &Value) -> Result { + let orientation = |key: &str| match p.get(key).map(|v| v.as_str()) { + None => Ok(None), + Some(Some("horizontal")) => Ok(Some(false)), + Some(Some("vertical")) => Ok(Some(true)), + Some(_) => Err(EngineError::Other(format!("`{key}` must be \"horizontal\" or \"vertical\""))), + }; + let from = orientation("orientation")?.unwrap_or(false); + let to = orientation("newOrientation")?.unwrap_or(from); + let index = p.get("index").and_then(Value::as_u64).ok_or(EngineError::Other("missing `index`".into()))?; + let pos = guide_position(s, p, to)?; + let color = guide_color(p)?; + s.edit("Edit Guide", |doc, _| { + let guides = &mut doc.guides; + let list = if from { &guides.vertical } else { &guides.horizontal }; + let i = usize::try_from(index).ok().filter(|i| *i < list.len()).ok_or(EngineError::Other("no such guide".into()))?; + let old = list[i]; + let at = pos.unwrap_or(old); + let rgb = color.or_else(|| guides.color(from, old)); + if from == to { + let list = if from { &mut guides.vertical } else { &mut guides.horizontal }; + list[i] = at; + } else { + (if from { &mut guides.vertical } else { &mut guides.horizontal }).remove(i); + (if to { &mut guides.vertical } else { &mut guides.horizontal }).push(at); + } + guides.prune_colors(); + if rgb.is_some() { + guides.set_color(to, at, rgb); + } + Ok(()) + })?; + Ok(Value::Null) +} + /// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states). fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical"; @@ -1014,6 +1052,15 @@ pub fn specs() -> Vec { has_doc, |s, p| guide_cmd(s, p, "move") ), + spec!( + "view.editGuide", + "Edit Guide", + &[], + None, + r##"{"orientation":"horizontal|vertical","index":n,"newOrientation":"horizontal|vertical"?,"position":px or ""?,"color":name or "#rrggbb"?} (Edit Selected Guides: units and colours as for view.newGuide; % is of the new orientation's extent; whatever is left out stays as it was)"##, + has_doc, + edit_guide + ), spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")), spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")), spec!( @@ -2082,6 +2129,60 @@ mod tests { assert_eq!(s.active().unwrap().history.can_undo(), undo_before); } + #[test] + fn editing_a_guide_changes_what_is_given_in_one_undoable_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + let guides = |s: &Session| s.active().unwrap().doc.guides.clone(); + // Position only: the guide keeps its index and its own colour. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "50%"})).unwrap(); + let g = guides(&s); + assert_eq!(g.vertical, vec![10.0, 200.0, 30.0]); + assert_eq!((g.color(true, 20.0), g.color(true, 200.0)), (None, Some([0xff, 0x00, 0xff]))); + // Colour only: an uncoloured guide gets one; the position stays. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "color": "#123456"})).unwrap(); + assert_eq!(guides(&s).color(true, 10.0), Some([0x12, 0x34, 0x56])); + // Turning it: % is of the new orientation's extent, and the colour goes along. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "newOrientation": "horizontal", "position": "25%"})).unwrap(); + let g = guides(&s); + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![10.0, 30.0], vec![50.0])); + assert_eq!((g.color(true, 200.0), g.color(false, 50.0)), (None, Some([0xff, 0x00, 0xff]))); + // One step each: undo restores the vertical guide with its colour. + s.undo(); + let g = guides(&s); + assert_eq!((g.vertical.clone(), g.horizontal.is_empty()), (vec![10.0, 200.0, 30.0], true)); + assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff])); + } + + #[test] + fn bad_guide_edits_are_refused_without_a_history_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": "green"})).unwrap(); + let before = s.active().unwrap().doc.guides.clone(); + for bad in [ + json!({}), + json!({"orientation": "vertical"}), + json!({"orientation": "vertical", "index": 1}), + json!({"orientation": "horizontal", "index": 0}), + json!({"orientation": "vertical", "index": -1}), + json!({"orientation": "vertical", "index": "0"}), + json!({"orientation": "vertical", "index": u64::MAX}), + json!({"orientation": "diagonal", "index": 0}), + json!({"orientation": "vertical", "index": 0, "newOrientation": 3}), + json!({"orientation": "vertical", "index": 0, "position": "far"}), + json!({"orientation": "vertical", "index": 0, "position": 1e12}), + json!({"orientation": "vertical", "index": 0, "color": "puce"}), + ] { + assert!(s.execute("view.editGuide", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.active().unwrap().doc.guides, before); + assert_eq!(s.active().unwrap().history.undo_label(), Some("New Guide")); + } + #[test] fn bad_guide_positions_are_refused_without_a_guide() { let mut s = Session::new(); diff --git a/crates/engine/src/export_preset_cmds.rs b/crates/engine/src/export_preset_cmds.rs new file mode 100644 index 0000000000..3d56c22437 --- /dev/null +++ b/crates/engine/src/export_preset_cmds.rs @@ -0,0 +1,290 @@ +//! Named Export As configurations. They persist with the other presets in the preferences +//! document; applying one only returns settings, and the export itself stays in Export As. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::{always, bad, req_str}; +use crate::{Result, Session}; + +const MAX_PRESETS: usize = 256; +const MAX_PX: u32 = 300_000; +const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportPresetSettings { + pub format: String, + pub quality: u8, + pub lossless: bool, + pub transparency: bool, + pub scale: f64, + /// Output pixels. Ignored when [`Self::sizing`] is `"scale"`. + pub width: u32, + pub height: u32, + pub lock_aspect: bool, + /// `"scale"` uses [`Self::scale`] on the current document; `"pixels"` uses width and height. + pub sizing: String, + /// `"none"` drops XMP and EXIF/resolution, `"exif"` keeps EXIF and resolution, `"all"` keeps both. + pub metadata: String, + /// `"document"` keeps the document profile; `"srgb"` converts to sRGB before export. + pub profile: String, + pub embed_profile: bool, + /// `"auto"`, `"24"` or `"32"`. + pub tga_bits: String, +} + +impl ExportPresetSettings { + fn validate(&self) -> std::result::Result<(), String> { + if !FORMATS.contains(&self.format.as_str()) { + return Err(format!("format must be one of {}", FORMATS.join(", "))); + } + if !(1..=100).contains(&self.quality) { + return Err("quality must be within 1..=100".into()); + } + if !self.scale.is_finite() || !(1.0..=1000.0).contains(&self.scale) { + return Err("scale must be within 1..=1000 percent".into()); + } + if !matches!(self.sizing.as_str(), "scale" | "pixels") { + return Err("sizing must be scale or pixels".into()); + } + if self.sizing == "pixels" && (!(1..=MAX_PX).contains(&self.width) || !(1..=MAX_PX).contains(&self.height)) { + return Err(format!("width and height must be within 1..={MAX_PX} pixels")); + } + if !matches!(self.metadata.as_str(), "none" | "exif" | "all") { + return Err("metadata must be none, exif or all".into()); + } + if !matches!(self.profile.as_str(), "document" | "srgb") { + return Err("profile must be document or srgb".into()); + } + if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") { + return Err("tgaBits must be auto, 24 or 32".into()); + } + Ok(()) + } + + fn from_params(p: &Value, cmd: &str) -> Result { + let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?; + let defaults = json!({ + "format": "png", + "quality": 85, + "lossless": false, + "transparency": true, + "scale": 100.0, + "width": 0, + "height": 0, + "lockAspect": true, + "sizing": "scale", + "metadata": "none", + "profile": "document", + "embedProfile": true, + "tgaBits": "auto" + }); + if let Some(defaults) = defaults.as_object() { + for (k, v) in defaults { + fields.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + for key in ["width", "height"] { + if let Some(n) = fields.get(key).and_then(Value::as_f64) { + if !n.is_finite() || n < 0.0 || n > f64::from(MAX_PX) { + return Err(bad(cmd, format!("`{key}` must be within 0..={MAX_PX} pixels"))); + } + fields.insert(key.into(), json!(n.round() as u32)); + } + } + if let Some(q) = fields.get("quality").and_then(Value::as_f64) { + if !q.is_finite() || !(1.0..=100.0).contains(&q) { + return Err(bad(cmd, "`quality` must be within 1..=100")); + } + fields.insert("quality".into(), json!(q.round() as u8)); + } + let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?; + settings.validate().map_err(|e| bad(cmd, e))?; + Ok(settings) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportPreset { + pub name: String, + pub settings: ExportPresetSettings, +} + +fn checked_name(name: &str) -> std::result::Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME { + return Err("Preset names must contain 1–255 characters after trimming".into()); + } + Ok(name.into()) +} + +/// Read entries independently so a damaged preset cannot discard other presets or preferences. +pub(crate) fn load(value: Value) -> Vec { + let Some(entries) = value.as_array() else { return Vec::new() }; + let mut presets: Vec = Vec::new(); + for entry in entries.iter().take(MAX_PRESETS) { + let Ok(mut preset) = serde_json::from_value::(entry.clone()) else { continue }; + let Ok(name) = checked_name(&preset.name) else { continue }; + if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) { + continue; + } + preset.name = name; + presets.push(preset); + } + presets +} + +fn save(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.save"; + let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?; + if s.presets.export_presets.len() >= MAX_PRESETS && s.presets.export_presets.iter().all(|pr| !pr.name.eq_ignore_ascii_case(&name)) { + return Err(bad(CMD, format!("At most {MAX_PRESETS} export presets can be saved"))); + } + let settings = ExportPresetSettings::from_params(p.get("settings").unwrap_or(&Value::Null), CMD)?; + let preset = ExportPreset { name: name.clone(), settings }; + if let Some(existing) = s.presets.export_presets.iter_mut().find(|pr| pr.name.eq_ignore_ascii_case(&name)) { + *existing = preset.clone(); + } else { + s.presets.export_presets.push(preset.clone()); + } + s.presets_changed(); + Ok(json!({"preset": preset})) +} + +fn get(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.get"; + let name = req_str(p, "name", CMD)?; + let preset = s.presets.export_presets.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?; + Ok(json!({"preset": preset})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.delete"; + let name = req_str(p, "name", CMD)?; + let index = s.presets.export_presets.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?; + s.presets.export_presets.remove(index); + s.presets_changed(); + Ok(json!({"deleted": name})) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "file.export.presets.list", + label: "List Export Presets", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, _| Ok(json!({"presets": s.presets.export_presets})), + journal: false, + }, + CommandSpec { + id: "file.export.presets.get", + label: "Get Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str} -> {preset} (settings for Export As; nothing is exported)"#, + enabled: always, + run: get, + journal: false, + }, + CommandSpec { + id: "file.export.presets.save", + label: "Save Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#, + enabled: always, + run: save, + journal: false, + }, + CommandSpec { + id: "file.export.presets.delete", + label: "Delete Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str}"#, + enabled: always, + run: delete, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(sizing: &str) -> Value { + json!({ + "format": "jpg", + "quality": 70, + "lossless": false, + "transparency": false, + "scale": 50, + "width": 640, + "height": 480, + "lockAspect": true, + "sizing": sizing, + "metadata": "exif", + "profile": "srgb", + "embedProfile": false, + "tgaBits": "24" + }) + } + + #[test] + fn presets_round_trip_through_the_preferences_document() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": " Web JPEG ", "settings": settings("pixels")})).unwrap(); + let again = s.execute("file.export.presets.save", json!({"name": "web jpeg", "settings": settings("scale")})).unwrap(); + assert_eq!(again["preset"]["settings"]["sizing"], "scale"); + assert_eq!(s.presets.export_presets.len(), 1, "saving the same name replaces it"); + + let raw = s.prefs_to_json(); + let mut restored = Session::new(); + restored.load_prefs_json(&raw).unwrap(); + let listed = restored.execute("file.export.presets.list", json!({})).unwrap(); + let presets = listed["presets"].as_array().unwrap(); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0]["name"], "web jpeg"); + assert_eq!(presets[0]["settings"]["format"], "jpg"); + assert_eq!(presets[0]["settings"]["width"], 640); + assert_eq!(presets[0]["settings"]["embedProfile"], false); + assert_eq!(presets[0]["settings"]["metadata"], "exif"); + + let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap(); + assert_eq!(got["preset"]["settings"]["profile"], "srgb"); + restored.execute("file.export.presets.delete", json!({"name": "web jpeg"})).unwrap(); + assert!(restored.execute("file.export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().is_empty()); + let raw = restored.prefs_to_json(); + let mut after = Session::new(); + after.load_prefs_json(&raw).unwrap(); + assert!(after.presets.export_presets.is_empty()); + } + + #[test] + fn bad_presets_are_rejected_and_a_damaged_entry_is_dropped() { + let mut s = Session::new(); + assert!(s.execute("file.export.presets.save", json!({})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": " ", "settings": settings("scale")})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"format": "bmp"}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"sizing": "pixels", "width": 0, "height": 10}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err()); + assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err()); + assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err()); + + let loaded = load(json!([ + {"name": "ok", "settings": {"format": "png", "quality": 85, "lossless": false, "transparency": true, "scale": 100.0, "width": 0, "height": 0, "lockAspect": true, "sizing": "scale", "metadata": "none", "profile": "document", "embedProfile": true, "tgaBits": "auto"}}, + {"name": "broken", "settings": {"format": "nope"}}, + "not an object" + ])); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].name, "ok"); + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7689f39679..43828b76b1 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -457,6 +457,20 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { } /// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given. +/// EXR/HDR rarely states its colour space, so io tags it with the linear-sRGB default. +/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1, +/// linear) — the working space ACES render pipelines write. Only the io default is +/// replaced; an embedded or EXIF-declared profile is kept untouched. +fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { + if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg { + return; + } + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + if doc.icc_profile.as_ref().map(|v| v.as_slice()) == Some(linear.as_slice()) { + doc.icc_profile = Some(photocraft_cms::Builtin::AcesCg.profile().to_bytes()); + } +} + pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { @@ -465,6 +479,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& }; let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; let mut doc = r.document; + tag_exr_colorspace(s, &decode_name, &mut doc); let path = match template_name(s, name) { Some(untitled) => { doc.name = untitled; @@ -930,13 +945,106 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { } } +/// One input of a batch run: the file, and the folder it sits in relative to the input folder +/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct BatchInput { + pub path: String, + pub rel_dir: String, +} + +/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders"). +pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32; +/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run +/// that silently stops partway. +pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000; + +/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch, +/// Image Processor): with `"includeSubfolders": true` a folder input also yields the images in its +/// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as +/// the dialog's default `/batch` is) is not walked, so a rerun doesn't process its results. +pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result> { + let recurse = match p.get("includeSubfolders") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }), + }; + match p.get("input").or_else(|| p.get("files")) { + Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd), + _ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()), + } +} + +/// The images under `dir` and its subfolders: each folder's files sorted by name, then its +/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and +/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip` +/// (the output folder) is left out. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { + use std::path::{Path, PathBuf}; + let canon = |p: &Path| std::fs::canonicalize(p).ok(); + let root = Path::new(dir); + let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?; + let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) }; + let mut seen = std::collections::HashSet::new(); + seen.insert(root_canon); + let mut out = Vec::new(); + // Depth first with an explicit stack (no recursion): (folder, relative folder, depth). + let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)]; + while let Some((folder, rel, depth)) = stack.pop() { + let shown = folder.to_string_lossy().into_owned(); + // The top folder must be readable; a subfolder that isn't is skipped. + let files = match list_images(&shown) { + Ok(f) => f, + Err(e) if depth == 0 => return Err(e), + Err(_) => continue, + }; + if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") }); + } + out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() })); + if depth >= MAX_SUBFOLDER_DEPTH { + continue; + } + let Ok(rd) = std::fs::read_dir(&folder) else { continue }; + let mut subs: Vec<(String, PathBuf)> = rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p))) + .collect(); + subs.sort(); + // Pushed in reverse so the stack pops them in name order. + for (name, sub) in subs.into_iter().rev() { + let Some(c) = canon(&sub) else { continue }; + if skip.as_ref() == Some(&c) || !seen.insert(c) { + continue; + } + let rel = if rel.is_empty() { name } else { format!("{rel}/{name}") }; + stack.push((sub, rel, depth + 1)); + } + } + Ok(out) +} +#[cfg(target_arch = "wasm32")] +pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result> { + Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) +} + /// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format` /// (`"same"` keeps the input's extension). Errors per file are collected, not fatal. pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { - let outputs = inputs.iter().map(|path| { + let inputs: Vec = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); + process_tree(&inputs, output, format, save, suffix, f) +} + +/// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder +/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. +pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { + let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { let ext = if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; - (path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path)))) + (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); process_outputs(outputs, save, f) } @@ -1000,8 +1108,8 @@ fn batch(s: &mut Session, p: &Value) -> Result { if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } - let inputs = batch_inputs(p, cmd)?; let output = str_param(p, "output", cmd)?.to_string(); + let inputs = batch_inputs_tree(p, cmd, &output)?; let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); // Steps that call another action play it from the caller's actions; the playback stack comes // along so an action that batches itself is caught as a recursive call. @@ -1032,23 +1140,23 @@ fn batch(s: &mut Session, p: &Value) -> Result { Ok(()) }; let r = match names { - Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run), - None => process_files(&inputs, &output, &format, save, "", &run), + Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), + None => process_tree(&inputs, &output, &format, save, "", &run), }; Ok(r) } fn image_processor(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.imageProcessor"; - let inputs = batch_inputs(p, cmd)?; let output = str_param(p, "output", cmd)?.to_string(); + let inputs = batch_inputs_tree(p, cmd, &output)?; let format = p.get("format").and_then(Value::as_str).unwrap_or("jpg").to_string(); let fit = match (size_param(p, "width"), size_param(p, "height")) { (None, None) => None, (w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})), }; let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false); - let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { + let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) { scratch.execute("image.mode.rgb", json!({}))?; } @@ -1549,7 +1657,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1558,7 +1666,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1616,3 +1724,41 @@ pub fn specs() -> Vec { #[cfg(test)] #[path = "file_cmds/tests.rs"] mod tests; + +#[cfg(test)] +mod aces_exr_tests { + use super::*; + + /// File Handling › "Open color-less EXR as ACEScg": off, an EXR opens on io's linear-sRGB + /// default; on, it opens as ACEScg — the working space ACES render pipelines write. + #[test] + fn exr_aces_cg_preference_retags_the_opened_document() { + let image = photocraft_codecs::Image::from_f32( + 4, + 4, + photocraft_codecs::ChannelLayout::Rgb, + &[ + 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, + 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, + ], + ) + .unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap(); + + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + + // Off (the default): io's linear-sRGB tagging stays. + let mut s = Session::new(); + open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(linear.as_slice())); + + // On: the same file opens as ACEScg. + let mut s = Session::new(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = true); + open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice())); + } +} diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs index 2a32f71328..e5dcc4a6e6 100644 --- a/crates/engine/src/file_cmds/batch_naming.rs +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -1,7 +1,7 @@ //! Batch output names are planned before opening or writing files. Names substituted from an //! input are literal: braces in a source filename must never be interpreted as template tokens. -use super::{EngineError, Result, Value, join, stem}; +use super::{BatchInput, EngineError, Result, Value, join, stem}; fn bad(message: impl Into) -> EngineError { EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() } @@ -51,7 +51,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String { /// An absent or empty template preserves the old filename path exactly. An explicit template /// generates one component per input; the export format supplies the extension separately. -pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result>> { +pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { let start = match params.get("startingSerial") { None => 1, Some(n) => { @@ -77,7 +77,7 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value return Err(bad("naming: the current UTC date is unavailable")); } let mut outputs = Vec::with_capacity(inputs.len()); - for (index, path) in inputs.iter().enumerate() { + for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() { let serial = if has_serial { u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))? } else { @@ -93,14 +93,15 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); } - outputs.push(join(output, &name)); + // From subfolders ("Include All Subfolders"), each result keeps its subfolder. + outputs.push(join(&join(output, rel_dir), &name)); } // Renaming within the source folder could replace a later input before it is read. Resolve // existing paths so aliases such as `source/./001.png` cannot bypass the check. The default // (no template) path keeps its existing in-place behavior. #[cfg(not(target_arch = "wasm32"))] { - let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect(); + let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect(); for output in &outputs { if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) { return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template"))); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 9b6567c2a8..3cc72c30c4 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1044,6 +1044,110 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); } + +/// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in +/// subfolders too, each result keeping its subfolder under the output folder (so same-named files +/// don't collide), and skip the output folder when it sits inside the input folder. +#[test] +fn batch_include_all_subfolders() { + let dir = tmp("batch-subfolders"); + let input = join(&dir, "in"); + for sub in ["sub/deeper", "z"] { + std::fs::create_dir_all(join(&input, sub)).unwrap(); + } + png(&input, "a.png", 8, 8, "#ff0000"); + png(&join(&input, "sub"), "b.png", 8, 8, "#00ff00"); + png(&join(&input, "sub/deeper"), "a.png", 8, 8, "#0000ff"); + png(&join(&input, "z"), "a.png", 8, 8, "#ffffff"); + let mut s = Session::new(); + // Default: the top level only, as before. + let out = join(&input, "batch"); + let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}"); + // With subfolders: every image, in folder order, results kept apart by subfolder; the output + // folder (inside the input folder, holding last run's a.png) is not an input. + let r = s + .execute( + "file.automate.batch", + json!({"steps": [["image.adjustments.invert", {}]], "input": input, "includeSubfolders": true, "output": out, "format": "png"}), + ) + .unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + let o = out.replace('\\', "/"); + assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]); + let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document; + let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0]; + assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}"); + std::fs::remove_dir_all(&out).unwrap(); + // A naming template applies within each kept subfolder (output outside the input folder). + let named = join(&dir, "named"); + let r = s + .execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"})) + .unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + let n = named.replace('\\', "/"); + assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]); + // Image Processor shares the option (with its results out of the input folder). + let out = join(&dir, "jpg"); + let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (4, 0), "{r}"); + assert!(std::path::Path::new(&join(&out, "sub/deeper/a.jpg")).is_file()); + // A wrong type is a parameter error, for both. + for bad in [json!("yes"), json!(1), json!([true])] { + for cmd in ["file.automate.batch", "file.scripts.imageProcessor"] { + let e = s.execute(cmd, json!({"steps": [], "input": input, "includeSubfolders": bad, "output": out})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{cmd} {bad}: {e}"); + } + } + // A missing input folder is an error, not a panic. + assert!(s.execute("file.automate.batch", json!({"steps": [], "input": join(&dir, "nope"), "includeSubfolders": true, "output": out})).is_err()); +} + +/// The subfolder walk stops at `MAX_SUBFOLDER_DEPTH` levels. +#[test] +fn batch_subfolder_walk_is_depth_limited() { + let dir = tmp("batch-subfolder-depth"); + let mut deep = dir.clone(); + for level in 1..=MAX_SUBFOLDER_DEPTH + 1 { + deep = join(&deep, "d"); + std::fs::create_dir_all(&deep).unwrap(); + if level >= MAX_SUBFOLDER_DEPTH { + std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap(); + } + } + let found = list_images_tree(&dir, "", "test").unwrap(); + let names: Vec<_> = found.iter().map(|i| file_name(&i.path)).collect(); + assert_eq!(names, vec![format!("l{MAX_SUBFOLDER_DEPTH}.png")], "{found:?}"); + assert_eq!(found[0].rel_dir.split('/').count(), MAX_SUBFOLDER_DEPTH); +} + +/// A symlink back up the tree is walked once, not forever. Skipped where the platform doesn't +/// let the test create directory symlinks (Windows without developer mode). +#[test] +fn batch_subfolder_walk_survives_symlink_loops() { + let dir = tmp("batch-subfolder-loop"); + let sub = join(&dir, "sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(join(&dir, "a.png"), b"x").unwrap(); + std::fs::write(join(&sub, "b.png"), b"x").unwrap(); + let link = std::path::Path::new(&sub).join("loop"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&dir, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&dir, &link); + #[cfg(not(any(unix, windows)))] + let made: std::io::Result<()> = Err(std::io::Error::other("no symlinks")); + if made.is_err() { + eprintln!("skipped: cannot create a directory symlink here"); + return; + } + let found = list_images_tree(&dir, "", "test").unwrap(); + let names: Vec<_> = found.iter().map(|i| (i.rel_dir.as_str(), file_name(&i.path))).collect(); + assert_eq!(names, vec![("", "a.png".to_string()), ("sub", "b.png".to_string())]); +} + #[test] fn batch_naming_validates_the_whole_plan_before_writing() { let dir = tmp("naming-invalid"); diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 68e617bc8c..2aa0a02bdc 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -235,24 +235,47 @@ pub(crate) fn blend_color_mask( /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight /// RGBA over the sampling window. Only the selected pixels are used. fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result { - use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; let hole_bounds = sel.content_bounds().intersect(&canvas); if hole_bounds.is_empty() { return Err(EngineError::Other("the selection is outside the canvas".into())); } - let window = sampling_window(hole_bounds, canvas); + let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0; + let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?; let fmt = surf.format(); - let n = fmt.channels(); + let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect(); + Ok(Source::Pixels(window, px)) +} + +/// The pixels `is_hole` picks inside `hole_bounds` filled from their surroundings +/// (`photocraft_algo::content_aware`): Photoshop's sampling window around `hole_bounds`, sampling +/// only the non-hole pixels `usable` allows. Returns the window and its pixels in the surface's +/// own channels (the hole filled, the rest as read). Shared by Edit › Fill › Content-Aware and the +/// Crop tool's Content-Aware fill (#3823). +pub(crate) fn content_aware_native( + surf: &Surface, + hole_bounds: Rect, + canvas: Rect, + color_adaptation: bool, + seed: u64, + is_hole: impl Fn(i32, i32) -> bool, + usable: impl Fn(i32, i32) -> bool, +) -> Result<(Rect, Vec)> { + use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; + let window = sampling_window(hole_bounds, canvas); + let n = surf.format().channels(); let (w, h) = window_size(window)?; let img = surf.read_region(window); - let mut hole = vec![false; w * h]; - for (i, hv) in hole.iter_mut().enumerate() { - *hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0; + let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?; + let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?; + let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?; + for (i, (hv, sv)) in hole.iter_mut().zip(source.iter_mut()).enumerate() { + let (x, y) = (window.x0 + (i % w) as i32, window.y0 + (i / w) as i32); + *hv = is_hole(x, y); + *sv = !*hv && usable(x, y); } - if hole.iter().all(|h| *h) { - return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into())); + if !source.iter().any(|s| *s) { + return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into())); } - let source = vec![true; w * h]; let opts = FillOptions { color_adaptation: color_level(if color_adaptation { "default" } else { "none" }), rotations: rotation_level("none"), @@ -260,12 +283,10 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: mirror: false, seed, }; - if img.len() != w * h * n { + if img.len() != len * n { return Err(EngineError::Other("could not read the layer pixels".into())); } - let filled = fill(w, h, n, &img, &hole, &source, &opts); - let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect(); - Ok(Source::Pixels(window, px)) + Ok((window, fill(w, h, n, &img, &hole, &source, &opts))) } /// Photoshop's Content-Aware sampling window around the selection bounds `hole` diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 127e92efe1..068d37e9da 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -1,4 +1,4 @@ -//! Validate new core-filter invocations, without changing how old smart-filter records decode. +//! Validate new filter invocations, without changing how old smart-filter records decode. use serde_json::Value; @@ -33,7 +33,7 @@ impl Rule { } } -// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in +// The bounds and choices are the filters' public dialog-unit contract. Defaults remain in // params_for; gallery filters are checked in gallery_cmds. fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { use Rule::{Bool, Choice, Number, Unsigned}; @@ -48,6 +48,20 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { ("centerX", Number(0.0, 1.0)), ("centerY", Number(0.0, 1.0)), ], + "filter.blur.smartBlur" => &[ + ("radius", Number(0.1, 100.0)), + ("threshold", Number(0.1, 100.0)), + ("quality", Choice("high|medium|low")), + ("mode", Choice("normal|edgeOnly|overlayEdge")), + ], + "filter.blur.shapeBlur" => &[("radius", Number(5.0, 1000.0)), ("shape", Choice("circle|ring|square|diamond|triangle|hexagon|star|heart|cross"))], + "filter.noise.reduceNoise" => &[ + ("strength", Number(0.0, 10.0)), + ("preserveDetails", Number(0.0, 100.0)), + ("reduceColorNoise", Number(0.0, 100.0)), + ("sharpenDetails", Number(0.0, 100.0)), + ("removeJpegArtifact", Bool), + ], "filter.blur.surfaceBlur" => &[("radius", Number(1.0, 100.0)), ("threshold", Number(2.0, 255.0))], "filter.sharpen.unsharpMask" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 1000.0)), ("threshold", Number(0.0, 255.0))], "filter.sharpen.smartSharpen" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 64.0)), ("reduceNoise", Number(0.0, 100.0))], @@ -92,8 +106,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { }) } -// Extended filters: only their documented choices are checked for now, since `prepare` injects -// extra keys (colours, map documents) and their numeric ranges clamp in params_for. +// Remaining extended filters: only their documented choices are checked for now, since some +// accept extra keys (colours, map documents) and their numeric ranges clamp in params_for. fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { Some(match id { "filter.pixelate.mezzotint" => { @@ -109,11 +123,9 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")], "filter.blur.lensBlur" => { &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] } - "filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, @@ -324,9 +336,7 @@ mod tests { "filter.distort.zigZag", "filter.render.lensFlare", "filter.render.lightingEffects", - "filter.blur.smartBlur", "filter.blur.lensBlur", - "filter.blur.shapeBlur", "filter.other.hsbHsl", "filter.video.deInterlace", ]; @@ -348,6 +358,124 @@ mod tests { s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap(); } + const STRICT_EXTENDED: [&str; 3] = ["filter.blur.smartBlur", "filter.blur.shapeBlur", "filter.noise.reduceNoise"]; + + #[test] + fn strict_extended_filters_reject_bad_fields_without_editing() { + let numbers = [ + ("filter.blur.smartBlur", "radius", 0.1, 100.0), + ("filter.blur.smartBlur", "threshold", 0.1, 100.0), + ("filter.blur.shapeBlur", "radius", 5.0, 1000.0), + ("filter.noise.reduceNoise", "strength", 0.0, 10.0), + ("filter.noise.reduceNoise", "preserveDetails", 0.0, 100.0), + ("filter.noise.reduceNoise", "reduceColorNoise", 0.0, 100.0), + ("filter.noise.reduceNoise", "sharpenDetails", 0.0, 100.0), + ]; + let specs = crate::filters_ext::specs(); + for depth in [8, 16, 32] { + let mut s = session(depth); + for (id, key, min, max) in numbers { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":{min}..{max}=")), "{id}.{key}"); + for value in [json!("3"), json!(true), Value::Null, json!([]), json!({}), json!(min - 0.01), json!(max + 0.01), json!(1e300)] { + rejected(&mut s, id, key, value); + } + // Validate large endpoints without allocating large blur kernels. + for value in [min, max] { + validate_params(id, &json!({key:value})).unwrap(); + } + } + for id in STRICT_EXTENDED { + for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] { + rejected(&mut s, id, key, value); + } + for value in [json!([]), json!(false), json!(3), json!("params")] { + assert!(matches!(validate_params(id, &value), Err(EngineError::BadParams { .. }))); + } + } + for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] { + rejected(&mut s, "filter.noise.reduceNoise", "removeJpegArtifact", value); + } + } + for value in [false, true] { + validate_params("filter.noise.reduceNoise", &json!({"removeJpegArtifact":value})).unwrap(); + } + for (id, key, allowed) in [ + ("filter.blur.smartBlur", "quality", "high|medium|low"), + ("filter.blur.smartBlur", "mode", "normal|edgeOnly|overlayEdge"), + ("filter.blur.shapeBlur", "shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross"), + ] { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}"); + let mut s = session(8); + for value in [json!("typo"), json!(false), json!(1), Value::Null] { + rejected(&mut s, id, key, value); + } + for value in allowed.split('|') { + validate_params(id, &json!({key:value})).unwrap(); + } + } + } + + #[test] + fn strict_extended_filters_validate_before_floating_selection_commit_and_jobs() { + let mut s = session(16); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":4})).unwrap(); + s.execute("select.float", json!({"dx":8})).unwrap(); + for id in STRICT_EXTENDED { + rejected(&mut s, id, "radus", json!(3)); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + let params = json!({"radus":3}); + assert!(matches!(s.start(id, params.clone()), Err(EngineError::BadParams { .. }))); + let spec = crate::commands::find(id).unwrap(); + assert!(matches!((spec.run)(&mut s, ¶ms), Err(EngineError::BadParams { .. }))); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some()); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + assert!(s.jobs().is_empty()); + } + } + + #[test] + fn strict_extended_filters_keep_defaults_targets_history_and_last_filter() { + for depth in [8, 16, 32] { + for id in STRICT_EXTENDED { + let mut s = session(depth); + let layer = s.active().unwrap().active_layer.unwrap(); + for params in [Value::Null, json!({}), json!({"layer":layer.0,"target":"pixels","coalesce":"extended"})] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute(id, params).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(*s.active().unwrap().doc, *after); + } + s.execute("filter.lastFilter", json!({})).unwrap(); + // The session injects this target when editing an alpha channel. + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute(id, json!({})).unwrap(); + } + } + } + + #[test] + fn stored_extended_filter_records_still_decode_tolerantly() { + use photocraft_algo::{BlurQuality, BlurShape, FilterParams, SmartBlurMode}; + let smart = + crate::filters::params_for("filter.blur.smartBlur", &json!({"radius":-2,"threshold":900,"quality":"old","mode":false,"extra":true})).unwrap(); + assert!(matches!(smart, FilterParams::SmartBlur { radius: 0.1, threshold: 100.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal })); + let shape = crate::filters::params_for("filter.blur.shapeBlur", &json!({"radius":2000,"shape":"old"})).unwrap(); + assert!(matches!(shape, FilterParams::ShapeBlur { radius: 1000.0, shape: BlurShape::Circle })); + let noise = crate::filters::params_for("filter.noise.reduceNoise", &json!({"strength":-1,"preserveDetails":101,"removeJpegArtifact":"yes"})).unwrap(); + assert!(matches!(noise, FilterParams::ReduceNoise { strength: 0.0, preserve_details: 100.0, remove_jpeg_artifact: false, .. })); + } + #[test] fn invalid_filters_leave_floating_selection_and_background_jobs_alone() { let mut s = session(16); diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index c97006e746..7a401594fd 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -3,7 +3,7 @@ //! primitives: group the selected layers, then clip the group with a rectangular layer mask the //! size of their bounds — the same visual result Photoshop's Frame tool produces for this command. //! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on -//! the canvas, empty or holding the pixel layer it was drawn over. Headless and scriptable like +//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like //! every other command. use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat}; @@ -107,8 +107,9 @@ fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask { } /// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one -/// history step. With `layer` (a pixel layer, not the Background), that layer becomes the frame's -/// content, as when a frame is drawn over a selected pixel layer in Photoshop; else it is empty. +/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background), +/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in +/// Photoshop; else it is empty. fn new_frame(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.new.frame"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -137,8 +138,9 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { let gid = match content { Some(id) => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) { - return Err(bad("only a pixel layer (not the Background) can go in a frame".into())); + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || doc.is_background(id) { + return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into())); } let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?; @@ -163,7 +165,7 @@ pub fn specs() -> Vec { label: "New Frame", menu: &[], shortcut: None, - params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel layer `layer` if given"#, + params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), journal: true, run: new_frame, @@ -274,6 +276,27 @@ mod tests { } } + /// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. + #[test] + fn new_frame_takes_a_type_layer() { + let (mut s, a, _) = session(); + let tid = s + .edit("setup", |doc, _| { + let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); + let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); + let id = t.id; + doc.layers.push(t); + Ok(id) + }) + .unwrap(); + let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); + } + #[test] fn needs_a_selected_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 15a71376b0..3e870aa686 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -71,11 +71,12 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer) } } -/// Assign new layer ids to a copied document and update every saved layer-comp reference. +/// Assign new layer ids to a copied document and update every saved layer reference. /// -/// A document copy needs fresh identities, but its comps must still target the copied -/// layers. Deliberately leave references to already-deleted layers untouched so their -/// missing-layer warnings remain meaningful. The same applies to Last Document State. +/// A document copy needs fresh identities, but its comps and variable definitions must +/// still target the copied layers. Deliberately leave references to already-deleted +/// layers untouched so their missing-layer warnings remain meaningful. The same applies +/// to Last Document State. pub(crate) fn reidentify_copied_layers(doc: &mut Document) { let mut ids = std::collections::HashMap::new(); for_each_layer(&mut doc.layers, &mut |layer| { @@ -97,6 +98,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { if let Some(last) = &mut doc.last_document_state { remap(last); } + for def in &mut doc.variables.defs { + if let Some(&new_id) = ids.get(&def.layer) { + def.layer = new_id; + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › @@ -428,6 +434,12 @@ const MAX_CROP_SIDE: i32 = 300_000; /// Image → Crop (to the selection bounds). fn crop(s: &mut Session, p: &Value) -> Result { let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true); + // The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823). + let content_aware = match p.get("contentAware") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))), + }; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). let explicit = match ( @@ -459,7 +471,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { } if let Some(a) = crop_angle(p)? { let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?; - return rotated_crop(s, rect, a, delete, &target); + return rotated_crop(s, rect, a, delete, content_aware, &target); } // An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped. let r = explicit @@ -470,8 +482,12 @@ fn crop(s: &mut Session, p: &Value) -> Result { } s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); let extension = s.tools.background; - s.edit("Crop", |doc, _| { + s.edit("Crop", |doc, active| { + let original = doc.size; crop_doc(doc, "image.crop", r, delete, extension)?; + if content_aware { + crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; + } doc.selection = None; target.apply(doc); Ok(()) @@ -579,8 +595,9 @@ fn crop_angle(p: &Value) -> Result> { /// Arbitrary: the canvas grows to fit, the Background fills what the rotation uncovers with the /// background colour, other layers keep transparency there and every pixel of theirs), which makes /// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in -/// one undo step. -fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result { +/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the +/// rotated image) are then filled from their surroundings, in the same step (#3823). +fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result { const CMD: &str = "image.crop"; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; let (w, h) = (f64::from(r.width()), f64::from(r.height())); @@ -601,13 +618,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: let end = |o: i32, len: u32| i32::try_from(len).ok().and_then(|l| o.checked_add(l)); let (Some(x1), Some(y1)) = (end(x, r.width()), end(y, r.height())) else { return Err(far()) }; let target = Rect::new(x, y, x1, y1); + // The cropped canvas back to the original document: undo the crop's offset, then the rotation + // (x = c·(x' − nx) + s·(y' − ny) + cx, y = −s·(x' − nx) + c·(y' − ny) + cy). + let (ox, oy) = (f64::from(target.x0) - f64::from(new.width) / 2.0, f64::from(target.y0) - f64::from(new.height) / 2.0); + let (ocx, ocy) = (f64::from(size.width) / 2.0, f64::from(size.height) / 2.0); + let coverage = crate::crop_fill::Coverage { + map: [cs, sn, -sn, cs, cs * ox + sn * oy + ocx, -sn * ox + cs * oy + ocy], + width: f64::from(size.width), + height: f64::from(size.height), + rotated: true, + }; crate::analysis_cmds::compound(s, "Crop", |s| { crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); let extension = s.tools.background; - s.edit("Crop", |doc, _| { + s.edit("Crop", |doc, active| { crop_doc(doc, CMD, target, delete, extension)?; + if content_aware { + crate::crop_fill::fill_uncovered(doc, *active, &coverage)?; + } doc.selection = None; crop_target.apply(doc); Ok(()) @@ -711,6 +741,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.layer_comps.clear(); doc.last_applied_comp = None; doc.last_document_state = None; + // Likewise, variables drove template layers that no longer exist: drop the whole + // binding set (defs, data sets and the active index) on the flattened copy. + doc.variables = photocraft_doc::Variables::default(); } reidentify_copied_layers(&mut doc); let name = doc.name.clone(); @@ -747,7 +780,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,"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)"##, + 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; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, has_doc, crop ), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 22c3d3a96f..6068503aec 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,6 +1,8 @@ //! JSON views of engine state for automation, tests and debugging. -use photocraft_doc::{Layer, LayerContent, SmartSource}; +use std::collections::HashMap; + +use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource}; use serde_json::{Value, json}; use crate::{DocState, Session}; @@ -25,6 +27,7 @@ pub fn session(s: &Session) -> Value { pub fn document(d: &DocState) -> Value { let doc = &d.doc; let selected = d.selected_layers(); + let instances = smart_instances(&doc.layers); json!({ "name": doc.name, "path": &d.path, @@ -38,7 +41,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), @@ -61,14 +64,27 @@ pub fn document(d: &DocState) -> Value { }) } -/// Layer tree top-to-bottom (display order). +/// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`. pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[], None) + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None) } -/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the -/// document's `meta`, a smart object whose file is embedded in the PSD says so. -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value { +/// How many smart objects in `layers`, groups included, share each contents identity. +fn smart_instances(layers: &[Layer]) -> HashMap { + let mut counts = HashMap::new(); + let mut pending: Vec<&Layer> = layers.iter().collect(); + while let Some(l) = pending.pop() { + match &l.content { + LayerContent::Smart(sm) => *counts.entry(sm.contents_id).or_default() += 1, + LayerContent::Group(g) => pending.extend(&g.children), + _ => {} + } + } + counts +} + +/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). +fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap, meta: Option<&photocraft_doc::Metadata>) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -92,7 +108,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -122,6 +138,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot None => json!({"kind": "linked", "path": path}), }, }; + // Copies of the same contents change together (Edit and Replace Contents), and + // `smartSource` can't tell them from independent copies of one file (#4075). + v["smartContents"] = json!({"id": sm.contents_id.0, "instances": instances.get(&sm.contents_id).copied().unwrap_or(1)}); } LayerContent::Text(t) => { v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt}); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dc43f4d794..527bc6f22b 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -382,6 +382,24 @@ pub fn edit_job( ) } +/// Scan every layer surface's content bounds into the shared tile bounds cache, so the first +/// draw of a document opened in the background doesn't do it on the UI thread: GPU planning +/// asks each layer for its bounds, a multi-second scan on a multi-gigabyte PSB (#4024). +/// `check` runs between layers (cancellation). +pub fn warm_layer_bounds(doc: &Document, mut check: impl FnMut() -> Result<()>) -> Result<()> { + let mut pending: Vec<&photocraft_doc::Layer> = doc.layers.iter().collect(); + while let Some(layer) = pending.pop() { + check()?; + if let Some(children) = layer.children() { + pending.extend(children); + } + if let Some(surface) = layer.surface() { + photocraft_compose::bounds::content_bounds(surface); + } + } + Ok(()) +} + /// Where a background open reads its file from. #[derive(Clone, Debug)] pub enum OpenSource { @@ -434,11 +452,15 @@ impl Session { }; ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) + let imported = ctx + .stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) .map_err(|e| match e { photocraft_io::IoError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), - }) + })?; + ctx.progress(0.95, "Preparing layers"); + warm_layer_bounds(&imported.document, || ctx.check())?; + Ok(imported) }, move |s, r: photocraft_io::ImportResult| { // Ask When Opening: held back until the prompt is answered (`take_held_open`). A @@ -724,6 +746,8 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + let params = + if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; self.journal.push((id.to_string(), params)); } } diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 0126922818..2bcdda76fc 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -490,6 +490,32 @@ fn an_open_that_asks_about_its_profile_holds_the_document_back() { assert_eq!(s.documents().len(), 1); } +#[test] +fn warm_layer_bounds_visits_every_layer_and_can_be_cancelled() { + let mut s = session(40, 30); + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + fn count(layers: &[photocraft_doc::Layer]) -> usize { + layers.iter().map(|l| 1 + l.children().map_or(0, count)).sum() + } + assert!(doc.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "a group with a layer in it"); + let mut checks = 0; + warm_layer_bounds(&doc, || { + checks += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(checks, count(&doc.layers), "one check per layer, nested ones included"); + let mut checks = 0; + let r = warm_layer_bounds(&doc, || { + checks += 1; + if checks > 1 { Err(EngineError::Cancelled) } else { Ok(()) } + }); + assert!(matches!(r, Err(EngineError::Cancelled))); + assert_eq!(checks, 2, "stops at the first failed check"); +} + #[test] fn wait_job_blocks_until_applied() { let mut s = session(400, 300); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index e3343ed1cd..a274a9cc6c 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -9,10 +9,11 @@ //! Where one label sits in several menus (Levels… under Image › Adjustments and Layer › New //! Adjustment Layer; All under Select, Edit › Purge, View › Show), the command whose default is //! one of the imported keys wins, then the one that has a default at all, then the first -//! candidate (the app lists them in Photoshop's menu order). Ours are one per command, so a -//! row's first shortcut is the one kept, unless one of its keys is already the command's -//! default (Zoom In lists `Cmd++` before `Cmd+=`). Tool keys are not imported: the tool -//! letters are fixed here. +//! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the +//! command's main key, unless one of its keys is already the command's default (Zoom In lists +//! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is +//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not +//! imported: the tool letters are fixed here. use std::collections::{BTreeMap, HashMap}; @@ -81,8 +82,12 @@ pub struct Plan { pub unknown: Vec, /// Rows whose shortcut could not be read (`unknown` does not list them). pub unreadable: Vec, - /// Second and later shortcuts of a row, which have no place here. + /// Second and later shortcuts of the matched rows (counted; kept in `alternate_keys`). pub alternates: usize, + /// Command id → the matched row's other keys (normalised, without the main key in `set`), + /// for every matched row: a set lists each command's whole binding, so an empty list + /// means the command has no alternates in it. + pub alternate_keys: BTreeMap>, } /// One command a row may match: its id, its label and its default shortcut. @@ -141,6 +146,13 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl // (Zoom In is "Cmd++" then "Cmd+=" in Photoshop; ours is Cmd+=). let default = default.as_deref().and_then(normalize_shortcut); let sc = default.filter(|d| imported.contains(d)).unwrap_or(sc); + let mut rest: Vec = Vec::new(); + for k in imported { + if k != sc && !rest.contains(&k) && rest.len() < crate::prefs::MAX_ALTERNATES { + rest.push(k); + } + } + plan.alternate_keys.insert(id.clone(), rest); plan.set.insert(id.clone(), sc); } } @@ -179,6 +191,18 @@ mod tests { assert_eq!(p.unknown, ["Hide Photoshop"]); assert_eq!(p.unreadable, ["Odd"]); assert_eq!(p.alternates, 1); + // #3665: the row's second key is kept as the command's alternate, not dropped. + assert_eq!(p.alternate_keys.get("edit.undo"), Some(&vec!["F1".to_string()])); + assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new())); + } + + #[test] + fn a_row_with_the_default_and_a_function_key_keeps_both() { + let xml = + "F3Cmd+C"; + let p = plan([c("edit.copy", "Copy", Some("Cmd+C"))], &parse(xml).unwrap()); + assert_eq!(p.set.get("edit.copy").map(String::as_str), Some("Cmd+C")); + assert_eq!(p.alternate_keys.get("edit.copy"), Some(&vec!["F3".to_string()])); } #[test] diff --git a/crates/engine/src/layer_list_cmds.rs b/crates/engine/src/layer_list_cmds.rs new file mode 100644 index 0000000000..6ced6ae0d9 --- /dev/null +++ b/crates/engine/src/layer_list_cmds.rs @@ -0,0 +1,163 @@ +//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has +//! it, buried among history, channels and much more; an agent looking for the layers' ids looks +//! for a `layer.*` command first. + +use photocraft_doc::{Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const ID: &str = "layer.list"; + +/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be +/// deep, and the tree is built recursively). +const MAX_DEPTH: usize = 256; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +/// One layer's row: what an agent needs to pick a layer to work on. +fn row(l: &Layer, selected: &[LayerId], active: Option) -> Value { + let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]); + json!({ + "id": l.id.0, + "name": l.name, + "kind": l.content.kind_name(), + "visible": l.visible, + "bounds": bounds, + "selected": selected.contains(&l.id), + "active": active == Some(l.id), + }) +} + +/// The layers top-down (the Layers panel's order), groups with their `children`. +fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec { + if depth >= MAX_DEPTH { + return Vec::new(); + } + layers + .iter() + .rev() + .map(|l| { + let mut v = row(l, selected, d.active_layer); + if let LayerContent::Group(g) = &l.content { + v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1)); + } + v + }) + .collect() +} + +/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id. +fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + for l in layers.iter().rev() { + let mut v = row(l, selected, d.active_layer); + v["depth"] = json!(depth); + v["parent"] = json!(parent.map(|p| p.0)); + out.push(v); + if let LayerContent::Group(g) = &l.content { + flat(&g.children, d, selected, Some(l.id), depth + 1, out); + } + } +} + +fn run(s: &mut Session, p: &Value) -> Result { + let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() }; + let d = match p.get("document") { + None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?, + Some(v) => { + let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?; + s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))? + } + }; + let as_flat = match p.get("flat") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?, + }; + let selected = d.selected_layers(); + let layers = if as_flat { + let mut out = Vec::new(); + flat(&d.doc.layers, d, &selected, None, 0, &mut out); + out + } else { + tree(&d.doc.layers, d, &selected, 0) + }; + Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "List Layers", + menu: &[], + shortcut: None, + params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##, + enabled: has_doc, + run, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s + } + + #[test] + fn lists_ids_names_kinds_and_nesting_top_down() { + let mut s = session_with_doc(); + let bg = s.active().unwrap().active_layer.unwrap().0; + let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().doc.layers.last().unwrap().id.0; + s.execute("layer.select", json!({"layer": inner})).unwrap(); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + let r = s.execute(ID, json!({})).unwrap(); + let layers = r["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2, "{r}"); + assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}"); + assert_eq!(layers[0]["children"][0]["id"], inner, "{r}"); + assert_eq!(layers[0]["children"][0]["active"], true, "{r}"); + assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}"); + assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children"); + assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false))); + assert!(layers[1].get("children").is_none()); + assert_eq!(r["activeLayer"], inner); + // Flat: one row per layer, with its depth and parent group. + let r = s.execute(ID, json!({"flat": true})).unwrap(); + let rows: Vec<(u64, u64, Option)> = + r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect(); + assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]); + } + + #[test] + fn is_a_query_and_leaves_the_document_alone() { + let mut s = session_with_doc(); + let revision = s.active().unwrap().revision; + let journal = s.journal.len(); + s.execute(ID, json!({})).unwrap(); + assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal)); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + assert!(s.execute(ID, json!({})).is_err(), "no document"); + let mut s = session_with_doc(); + for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] { + assert!(s.execute(ID, p.clone()).is_err(), "{p}"); + } + assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok()); + } +} diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 80da3bdd81..9a79a72050 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -100,6 +100,48 @@ pub(crate) fn reselect(s: &mut Session, ids: Vec, active: Option Value { + let Some(id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(st) = s.active() else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect(); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + fields.insert("name".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("occurrence".into(), json!(occurrence)); + } + } + p +} + +fn selection_target(s: &Session, p: &Value) -> Result { + let Some(name) = p.get("name") else { + if p.get("occurrence").is_some() { + return Err(bad("layer.select", "`occurrence` requires `name`")); + } + return crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "needs `layer` or `name`")); + }; + if p.get("layer").is_some() { + return Err(bad("layer.select", "use either `layer` or `name`, not both")); + } + let name = name.as_str().ok_or_else(|| bad("layer.select", "`name` must be a string"))?; + let occurrence = p + .get("occurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("layer.select", "`occurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).collect(); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad("layer.select", format!("multiple layers named `{name}`; specify `occurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad("layer.select", format!("no layer named `{name}` at occurrence {index}"))) +} + /// Layer rows follow the Layers panel's visual order, not Document::walk's storage order. /// Closed groups hide their children unless both endpoints were selected programmatically. fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec) { @@ -115,7 +157,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; + let id = selection_target(s, p)?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { @@ -1270,7 +1312,7 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. for id in &ids[..ids.len() - 1] { @@ -2051,6 +2093,37 @@ mod tests { assert!(!s.is_enabled("layer.distribute.leftEdges")); } + #[test] + fn merge_keeps_pixels_outside_the_canvas() { + // #5067: the merged layer was cropped to the 100x80 canvas. + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let ml = doc(&s).layer(m).unwrap(); + assert_eq!(layer_bounds(ml).unwrap(), Rect::new(-30, -20, 140, 120)); + assert_eq!(ml.surface().unwrap().pixel(-10, -5)[3], 1.0); + assert_eq!(ml.surface().unwrap().pixel(130, 110)[3], 1.0); + } + + #[test] + fn merge_down_and_merge_group_keep_pixels_outside_the_canvas() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + s.execute("layer.mergeDown", json!({"layer": b.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(a).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.mergeGroup", json!({"layer": g.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(g).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + } + #[test] fn merge_selected_into_top_most() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 5f7b8f0eee..95b5476850 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -98,7 +98,7 @@ pub fn effect_defaults(kind: &str) -> Value { } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { - json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) + json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) } "satin" => { json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true}) @@ -895,8 +895,10 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { } let mode = doc.mode; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye) + // survives the edit: the dialog models per-effect state, not the switch (#3648). A layer + // with no effects yet still has the default `enabled: true`, so its style shows. l.effects.items = list.into_iter().map(|e| e.in_mode(mode)).collect(); - l.effects.enabled = true; Ok(()) })?; Ok(json!({ "layer": id.0, "effects": count })) @@ -915,7 +917,8 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { // Colours in the document's mode, as Image › Mode keeps them. let fx = fx.in_mode(doc.mode); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - l.effects.enabled = true; + // Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh + // effects list already has the default `enabled: true`, so a first effect still shows. match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) { Some(slot) if !add => *slot = fx, _ => l.effects.items.push(fx), @@ -1197,6 +1200,62 @@ mod tests { assert_eq!(effects(&s).len(), 1); } + fn pixel(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap() + } + + fn list_enabled(s: &Session) -> bool { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().effects.enabled + } + + /// #3648: the Layers panel's "Effects" eye (`layer.setEffectsVisible`) must survive a Layer + /// Style dialog OK: the dialog's whole-list replace re-creates the items but leaves the + /// list's master switch alone. + #[test] + fn replacing_the_list_keeps_a_disabled_effect_list_disabled() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap(); + let shown = pixel(&mut s); + s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap(); + let hidden = pixel(&mut s); + assert_ne!(hidden, shown, "the eye really hides the style"); + let r = s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "colorOverlay", "params": {"color": "#00ff00"}}]})).unwrap(); + assert_eq!(r["effects"], 1, "the list itself is updated"); + assert!(!list_enabled(&s), "the whole-list switch survives the edit"); + assert!(effects(&s)[0].enabled(), "the effect's own state is updated"); + assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing"); + s.execute("layer.setEffectsVisible", json!({"visible": true})).unwrap(); + assert_ne!(pixel(&mut s), hidden, "re-showing the list draws the edited effect"); + } + + /// #3648: adding or replacing one effect (`layer.layerStyle.`) also leaves the master + /// switch as the user left it. + #[test] + fn set_effect_keeps_a_disabled_effect_list_disabled() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap(); + let hidden = pixel(&mut s); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap(); + assert!(!list_enabled(&s), "the whole-list switch survives the edit"); + assert!(effects(&s)[0].enabled(), "the effect's own state is updated"); + assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing"); + } + + /// A layer that never had effects keeps the default: its first effect shows. + #[test] + fn a_first_effect_shows_on_a_layer_that_never_had_effects() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let plain = pixel(&mut s); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap(); + assert!(list_enabled(&s), "a fresh effects list defaults to enabled"); + assert_ne!(pixel(&mut s), plain, "the first effect renders"); + } + /// #3102: an effect row dropped on the Delete button deletes only that effect, in one step. #[test] fn clear_one_effect_by_index() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff3cde6e4c..75914dbdbb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ pub mod color_cmds; pub mod color_to_alpha_cmds; pub mod commands; pub mod comps_cmds; +mod crop_fill; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; @@ -37,6 +38,7 @@ pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; pub mod eraser_cmds; +pub mod export_preset_cmds; pub mod exr_cmds; pub mod extra_cmds; pub mod file_cmds; @@ -62,6 +64,7 @@ pub mod jobs; pub mod kys; pub mod layer_copy_cmds; pub mod layer_label_cmds; +pub mod layer_list_cmds; pub mod layer_mask_props_cmds; pub mod layer_menu_cmds; pub mod layer_multi_cmds; diff --git a/crates/engine/src/path_edit_cmds.rs b/crates/engine/src/path_edit_cmds.rs index 6db84c270c..503ac8efae 100644 --- a/crates/engine/src/path_edit_cmds.rs +++ b/crates/engine/src/path_edit_cmds.rs @@ -80,6 +80,9 @@ fn move_anchors(s: &mut Session, p: &Value) -> Result { }) .collect::>()?; let d = offset(CMD, p)?; + if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) { + return edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors_curvature(path, &anchors, d)); + } edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors(path, &anchors, d)) } @@ -106,6 +109,9 @@ fn bend_segment(s: &mut Session, p: &Value) -> Result { fn convert_point(s: &mut Session, p: &Value) -> Result { const CMD: &str = "path.convertPoint"; let k = knot(CMD, p)?; + if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) { + return edit_path(s, CMD, p, "Convert Point", |path| edit::toggle_curvature_point(path, k)); + } let out = point(CMD, p, "out")?; edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out)) } @@ -155,7 +161,7 @@ pub fn specs() -> Vec { journal: true, }; vec![ - spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy] (anchors move with their handles; Direct Selection drag)"#.into(), move_anchors), + spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy],"curvature":bool=false (anchors move with their handles; Direct Selection drag. curvature = the Curvature Pen's drag: every smooth knot of the touched subpaths gets its handles recomputed so the curve flows through the anchors)"#.into(), move_anchors), spec( "path.moveHandle", "Drag Direction Point", @@ -171,7 +177,7 @@ pub fn specs() -> Vec { spec( "path.convertPoint", "Convert Point", - r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror)"#.into(), + r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored)"#.into(), convert_point, ), spec( @@ -226,6 +232,36 @@ mod tests { assert_eq!(work(&s), before); } + #[test] + fn a_curvature_anchor_drag_reflows_the_smooth_knots() { + let mut s = session(); + let smooth = |a: [f64; 2]| json!({"anchor": a, "in": a, "out": a, "smooth": true}); + let path = json!({"subpaths": [{"closed": true, "knots": [smooth([0.0, 50.0]), smooth([100.0, 0.0]), smooth([200.0, 50.0]), smooth([100.0, 100.0])]}]}); + s.execute("path.set", json!({"path": path})).unwrap(); + s.execute("path.moveAnchors", json!({"anchors": [[0, 1]], "move": [0, 60], "curvature": true})).unwrap(); + let p = work(&s); + let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]]; + let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 4], true); + for (k, w) in p.subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } + assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points")); + assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 0], "curvature": true})).is_err()); + } + + #[test] + fn a_curvature_convert_toggles_the_point_and_reflows() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let before = work(&s); + s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap(); + let k = work(&s).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "{k:?}"); + s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap(); + assert_eq!(work(&s), before); + assert!(s.execute("path.convertPoint", json!({"subpath": 0, "knot": 9, "curvature": true})).is_err()); + } + #[test] fn a_coalesced_drag_is_one_history_step() { let mut s = session(); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index cde7fa41db..e0cea4f39e 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -28,6 +28,36 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, photocraft_compose::composite_to_surface(solo, area, format, background) } +/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept +/// (merging layers must not lose them, #5067). +pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface { + let canvas = solo.bounds(); + // The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`. + let reach = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| { + let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 }; + let bounds = photocraft_compose::layer_bounds(layer, canvas); + if bounds.is_empty() { bounds } else { bounds.inflate(margin) } + }) + .filter(|bounds| !bounds.is_empty()) + .fold(canvas, |a, b| a.union(&b)); + let area = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas)) + .filter(|bounds| !bounds.is_empty()) + .reduce(|a, b| a.union(&b)) + .unwrap_or(Rect::EMPTY); + if area.is_empty() { + return Surface::new(format); + } + photocraft_compose::composite_to_surface(solo, area, format, None) +} + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). @@ -92,23 +122,39 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec { let fmt = s.format(); if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) { - return adjusted_channels(s, r, adj, selection); + return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch)); + } + if fmt.mode == mode && mode == photocraft_color::ColorMode::Cmyk && adjust::cmy_adjustment(adj) { + return adjusted_cmy(s, r, adj, selection); + } + if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj + && fmt.mode == mode + && mode == photocraft_color::ColorMode::Cmyk + { + let table = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + return adjusted_channels(s, r, selection, |ch| ch.iter_mut().for_each(|v| *v = adjust::ink_through(&table, *v))); } let n = fmt.channels(); let raw = s.read_region(r); let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; let orig = buf.clone(); // 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip); - // integer depths keep the unrounded, clipped curves. - let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); + // integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and + // Threshold use the 16-bit rules an adjustment layer measures against Photoshop, so the + // command matches its live preview (an adjustment layer) and Photoshop. + let depth = match (fmt.sample, adj) { + (photocraft_color::SampleType::F32, _) => Some(fmt.sample), + (photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample), + _ => None, + }; adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) } -/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels -/// (`adjust::stored_channels`): exact on the stored values, with no trip through RGB, and mixed -/// through the selection on those values (alpha untouched). -fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec { +/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels: `op` +/// changes one pixel's colour channels, exact on the stored values with no trip through RGB, and +/// the selection mixes those values (alpha untouched). +fn adjusted_channels(s: &Surface, r: Rect, selection: Option<&Surface>, op: impl Fn(&mut [f32])) -> Vec { let fmt = s.format(); let (n, colors) = (fmt.channels(), fmt.mode.color_channels()); let mut raw = s.read_region(r); @@ -122,13 +168,11 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<& if k <= 0.0 { continue; } - let p = to_rgba(&fmt, px); - let gray = photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]); let Some(ch) = px.get_mut(..colors) else { continue }; let mut out = [0.0f32; 4]; let Some(out) = out.get_mut(..colors) else { continue }; out.copy_from_slice(ch); - adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), out, gray); + op(out); for (v, o) in ch.iter_mut().zip(out.iter()) { *v += (o - *v) * k; } @@ -137,6 +181,29 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<& raw } +/// [`adjusted`] for Hue/Saturation or Color Balance on a CMYK layer (`adjust::apply_cmy`): on its +/// stored C, M and Y, black kept, the whole region at once; the selection mixes the stored inks. +fn adjusted_cmy(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec { + let fmt = s.format(); + let n = fmt.channels(); + let mut raw = s.read_region(r); + let mut inks: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| std::array::from_fn(|c| p.get(c).copied().unwrap_or(0.0))).collect(); + adjust::apply_cmy(adj, &mut inks); + let w = (r.width() as usize).max(1); + let mut coverage = vec![1.0f32; w]; + for (j, (row, adjusted)) in raw.chunks_mut(w * n).zip(inks.chunks(w)).enumerate() { + if let Some(sel) = selection { + sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); + } + for ((px, ink), &k) in row.chunks_exact_mut(n).zip(adjusted).zip(&coverage) { + for (v, a) in px.iter_mut().zip(ink) { + *v += (a - *v) * k; + } + } + } + raw +} + /// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's /// coverage, encoded in `fmt`: what a destructive edit writes back through a selection. pub(crate) fn mix_through_selection( @@ -339,7 +406,8 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // The pair is read (and a CMYK one separated again) through the document's own profile. tmp.icc_profile = doc.icc_profile.clone(); tmp.layers = stack; - let s = composite_layers(&tmp, format, None); + // A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067). + let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) }; let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); merged.id = lower.id; merged.blend = lower.blend; diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 965544307a..5af30d0e76 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -349,3 +349,48 @@ fn invert_posterize_and_threshold_change_lab_and_cmyk_channels_as_stored() { assert_levels(&adjusted_pixel(lab8, &f([128.0, 30.0, 220.0, 255.0]), &level, None), &[255.0, 128.0, 128.0, 255.0], "Lab threshold light"); assert_levels(&adjusted_pixel(lab16, &f([127.0, 30.0, 220.0, 255.0]), &level, None), &[0.0, 128.0, 128.0, 255.0], "Lab threshold dark"); } + +/// Image › Adjustments › Brightness/Contrast on a CMYK layer curves each stored ink value +/// (1 - ink) exactly, modern and legacy, and a selection mixes the stored inks. +#[test] +fn brightness_contrast_curves_cmyk_inks_as_stored() { + let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true); + let ink = [0.2, 0.4, 0.6, 0.1, 1.0]; + for legacy in [false, true] { + let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy }; + let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, legacy); + let got = adjusted_pixel(cmyk, &ink, &adj, None); + for c in 0..4 { + let want = adjust::ink_through(&table, ink[c]); + assert!((got[c] - want).abs() < 1e-4, "legacy {legacy} ink {c}: {} vs {want}", got[c]); + } + assert_eq!(got[4], 1.0); + let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5)); + for c in 0..4 { + let want = ink[c] + (adjust::ink_through(&table, ink[c]) - ink[c]) * 128.0 / 255.0; + assert!((half[c] - want).abs() < 1e-3, "legacy {legacy} ink {c} through half a selection"); + } + } +} + +/// Image › Adjustments › Hue/Saturation and Color Balance on a CMYK layer work on its stored C, M +/// and Y as inverted RGB, keep its black ink exactly, and mix through a selection. +#[test] +fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() { + let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true); + let ink = [0.2, 0.7, 0.1, 0.3, 1.0]; + let hue = + Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() }; + let balance = + Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true }; + for adj in [hue, balance] { + let mut want = [[ink[0], ink[1], ink[2], ink[3]]]; + adjust::apply_cmy(&adj, &mut want); + let got = adjusted_pixel(cmyk, &ink, &adj, None); + assert!((0..4).all(|c| (got[c] - want[0][c]).abs() < 1e-4), "{}: {got:?} vs {want:?}", adj.label()); + assert!((got[3] - ink[3]).abs() < 1e-4 && got[4] == 1.0, "{}: black and alpha kept", adj.label()); + let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5)); + let k = 128.0 / 255.0; + assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label()); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b163d40d88..d6b40178d3 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -257,6 +257,9 @@ pub struct Interface { /// Show the repeating dot pattern on the canvas surround (including the empty workspace). /// Disable for a uniform background, for example on OLED displays (#2264). pub show_canvas_dots: bool, + /// List recent files on the Home screen (the empty workspace). Off hides the list; the + /// recent files themselves are kept and File > Open Recent still shows them. + pub show_home_recent_files: bool, /// Document border in standard screen mode. pub canvas_border: CanvasBorder, pub ui_scale: UiScale, @@ -269,6 +272,9 @@ pub struct Interface { pub dynamic_color_sliders: bool, /// Draw menu item colours set with Edit › Menus. pub show_menu_colors: bool, + /// Layers panel thumbnails frame the layer's own pixels instead of the whole canvas, so small + /// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281). + pub clip_thumbnails_to_layer_bounds: bool, pub show_tooltips: bool, /// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new /// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). @@ -298,6 +304,7 @@ impl Default for Interface { canvas_color: CanvasColor::Default, canvas_custom_color: "#282828".into(), show_canvas_dots: true, + show_home_recent_files: true, canvas_border: CanvasBorder::DropShadow, ui_scale: UiScale::Auto, language: "auto".into(), @@ -305,6 +312,7 @@ impl Default for Interface { show_channels_in_color: false, dynamic_color_sliders: true, show_menu_colors: true, + clip_thumbnails_to_layer_bounds: false, show_tooltips: true, enable_animations: true, show_bounding_box_when_dragging_layer: false, @@ -374,6 +382,13 @@ pub struct Tools { /// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure /// passes through before it reaches any brush ([`PressureCurve`]). Linear by default. pub pressure_curve: Vec<[f32; 2]>, + /// Arrow-key nudges of one thing (the selected layers, the selection outline, the Free + /// Transform box) that follow each other within [`Tools::nudge_bundle_pause_ms`] are one + /// History state (#2259). Off: every press is a state. + pub bundle_nudges: bool, + /// The longest pause, in milliseconds, between two nudges that still bundle into one state. + /// Longer than a key's auto-repeat delay (about half a second), so a held key is one state. + pub nudge_bundle_pause_ms: u32, } impl Default for Tools { @@ -392,104 +407,16 @@ impl Default for Tools { right_click_with_painting_tools: RightClickPaint::BrushPicker, use_tablet_pressure: true, pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]], + bundle_nudges: true, + nudge_bundle_pause_ms: 1000, } } } -/// Preferences › Tools › Pressure Curve, ready to evaluate: a monotone cubic (Fritsch–Carlson) -/// through the control points, so a firmer press never gives less pressure. The points are -/// sanitised here, not trusted: a whole-section update or a hand-edited preferences file can store -/// anything. Non-finite points are dropped, the rest clamped to 0..1, sorted by input (one point -/// per input), capped at [`Self::MAX_POINTS`], and their outputs made non-decreasing; before the -/// first point and after the last the curve stays flat. Fewer than two usable points is linear. -#[derive(Clone, Debug, PartialEq)] -pub struct PressureCurve { - xs: Vec, - ys: Vec, - /// Tangents at the points. - ms: Vec, -} - -impl PressureCurve { - pub const MAX_POINTS: usize = 16; - - pub fn new(points: &[[f32; 2]]) -> Self { - let mut pts: Vec<[f32; 2]> = - points.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect(); - pts.sort_by(|a, b| a[0].total_cmp(&b[0])); - pts.dedup_by(|b, a| { - // Same input: keep the later point's output. - let same = (a[0] - b[0]).abs() < 1e-6; - if same { - a[1] = b[1]; - } - same - }); - pts.truncate(Self::MAX_POINTS); - if pts.len() < 2 { - pts = vec![[0.0, 0.0], [1.0, 1.0]]; - } - let xs: Vec = pts.iter().map(|p| p[0]).collect(); - let mut ys: Vec = pts.iter().map(|p| p[1]).collect(); - for i in 1..ys.len() { - ys[i] = ys[i].max(ys[i - 1]); - } - // Fritsch–Carlson tangents: secant averages, zero at flat steps, limited so the cubic - // never overshoots (which keeps it monotone). - let n = xs.len(); - let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); - let mut ms = vec![0.0f32; n]; - ms[0] = d[0]; - ms[n - 1] = d[n - 2]; - for k in 1..n - 1 { - ms[k] = if d[k - 1] * d[k] <= 0.0 { 0.0 } else { (d[k - 1] + d[k]) / 2.0 }; - } - for k in 0..n - 1 { - if d[k] == 0.0 { - ms[k] = 0.0; - ms[k + 1] = 0.0; - continue; - } - let (a, b) = (ms[k] / d[k], ms[k + 1] / d[k]); - let s = a * a + b * b; - if s > 9.0 { - let t = 3.0 / s.sqrt(); - ms[k] = t * a * d[k]; - ms[k + 1] = t * b * d[k]; - } - } - Self { xs, ys, ms } - } - - /// The curve's output for pen pressure `x` (0..1; anything else is clamped, NaN reads as 0). - pub fn eval(&self, x: f32) -> f32 { - let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; - let n = self.xs.len(); - let (Some(&x0), Some(&xn)) = (self.xs.first(), self.xs.last()) else { return x }; - if x <= x0 { - return self.ys.first().copied().unwrap_or(x); - } - if x >= xn { - return self.ys.last().copied().unwrap_or(x); - } - let k = self.xs.partition_point(|&v| v <= x).saturating_sub(1).min(n.saturating_sub(2)); - let (Some(&xa), Some(&xb), Some(&ya), Some(&yb), Some(&ma), Some(&mb)) = - (self.xs.get(k), self.xs.get(k + 1), self.ys.get(k), self.ys.get(k + 1), self.ms.get(k), self.ms.get(k + 1)) - else { - return x; - }; - let h = xb - xa; - let t = (x - xa) / h; - let (t2, t3) = (t * t, t * t * t); - let y = (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * ma + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * mb; - y.clamp(ya.min(yb), ya.max(yb)) - } - - /// The identity (the default curve): pressure passes through unchanged. - pub fn is_linear(&self) -> bool { - self.xs == [0.0, 1.0] && self.ys == [0.0, 1.0] - } -} +/// Preferences › Tools › Pressure Curve, ready to evaluate: the same monotone cubic every brush +/// dynamic uses for its own control ([`photocraft_paint::ResponseCurve`]), sanitised the same way, +/// since a whole-section update or a hand-edited preferences file can store anything. +pub use photocraft_paint::ResponseCurve as PressureCurve; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -521,6 +448,9 @@ 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, + /// 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, pub ask_before_saving_layered_tiff: bool, pub maximize_psd_compatibility: Ask, /// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed. @@ -540,6 +470,7 @@ impl Default for FileHandling { autosave_minutes: 10, recover_on_launch: true, ignore_exif_profile_tag: false, + exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32, @@ -954,6 +885,20 @@ pub struct MenuCustomization { pub colors: BTreeMap, } +impl MenuCustomization { + /// False for the items Edit › Menus can't hide (#5096): the way back into this dialog + /// (Edit › Menus, Edit › Keyboard Shortcuts), Preferences, Save, Save As and Exit. + pub fn hideable(id: &str) -> bool { + !matches!(id, "edit.menus" | "edit.keyboardShortcuts" | "file.save" | "file.saveAs" | "file.exit") && !id.starts_with("edit.preferences") + } + + /// Whether the menus leave `id` out. Items that can't be hidden stay shown even when an + /// older or hand-edited preferences file lists them. + pub fn is_hidden(&self, id: &str) -> bool { + Self::hideable(id) && self.hidden.iter().any(|h| h == id) + } +} + /// Edit › Toolbar: hidden tools and a custom order (tool names as in `ui.set {tool}`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -988,6 +933,11 @@ pub struct Preferences { /// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty /// string removes the default shortcut. pub shortcuts: BTreeMap, + /// Edit › Keyboard Shortcuts: a command's further keys besides its main one (#3665), + /// command id → keys. A list replaces the command's default alternates + /// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this + /// map load with nothing stored, so every command keeps its defaults. + pub alternate_shortcuts: BTreeMap>, pub menus: MenuCustomization, pub toolbar: ToolbarCustomization, /// Edit › Check Spelling: words added to the dictionary ("Add"). @@ -1015,6 +965,9 @@ pub struct Preferences { /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. pub tool_options: Value, + /// Last selected tool, restored at launch. A tool-name string owned and validated by the + /// shell; null in older files. Kept separate from tool options so neither overwrites the other. + pub selected_tool: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. @@ -1137,6 +1090,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { Some(match path { "fileHandling.autosaveMinutes" => (1.0, 240.0), "fileHandling.recentFileCount" => (0.0, 100.0), + "tools.nudgeBundlePauseMs" => (100.0, 10_000.0), "fileHandling.rasterizeSvgGroupsDeeperThan" => (0.0, photocraft_doc::MAX_GROUP_DEPTH as f64), "interface.notificationDurationSeconds" => (1.0, 120.0), "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), @@ -1158,7 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { /// Is this preference a `#rrggbb` colour? pub fn is_color(path: &str) -> bool { - if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("menus.") { + if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") { return false; } let leaf = path.rsplit('.').next().unwrap_or(path); @@ -1187,9 +1141,10 @@ pub fn parse_hex(s: &str) -> Option<[u8; 3]> { // ------------------------------------------------------------------ JSON paths -/// Map keys that are command ids (which contain dots): `shortcuts.`, `menus.colors.`. +/// Map keys that are command ids (which contain dots): `shortcuts.`, +/// `alternateShortcuts.`, `menus.colors.`. fn keyed(path: &str) -> Option<(&str, &str)> { - for prefix in ["shortcuts.", "menus.colors."] { + for prefix in ["shortcuts.", "alternateShortcuts.", "menus.colors."] { if let Some(id) = path.strip_prefix(prefix) { return Some((&prefix[..prefix.len() - 1], id)); } @@ -1306,6 +1261,28 @@ impl Preferences { /// Set a value by dotted path, validating it. Unknown paths and wrong types are errors. pub fn set(&mut self, path: &str, value: Value) -> std::result::Result<(), String> { + if path == "alternateShortcuts" { + let obj = value.as_object().ok_or("`alternateShortcuts` maps command ids to lists of shortcuts")?; + let mut map = BTreeMap::new(); + for (id, v) in obj { + map.insert(id.clone(), alternate_keys(v).map_err(|e| format!("`alternateShortcuts.{id}`: {e}"))?); + } + self.alternate_shortcuts = map; + return Ok(()); + } + if let Some(id) = path.strip_prefix("alternateShortcuts.") { + // null = back to the command's default alternates. + match value { + Value::Null => { + self.alternate_shortcuts.remove(id); + } + v => { + let keys = alternate_keys(&v).map_err(|e| format!("`{path}`: {e}"))?; + self.alternate_shortcuts.insert(id.to_string(), keys); + } + } + return Ok(()); + } let value = match (path.strip_prefix("shortcuts."), &value) { // Normalise shortcut spelling so conflicts compare equal. (Some(_), Value::String(s)) if !s.is_empty() => Value::String(normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut"))?), @@ -1336,6 +1313,10 @@ impl Preferences { self.shortcuts.remove(id); return Ok(()); } + Some(("alternateShortcuts", id)) => { + self.alternate_shortcuts.remove(id); + return Ok(()); + } Some(("menus.colors", id)) => { self.menus.colors.remove(id); return Ok(()); @@ -1361,6 +1342,50 @@ impl Preferences { None => default, } } + + /// A command's further keys in effect (#3665): the user's list, else its defaults + /// ([`ALTERNATE_SHORTCUTS`]), normalised, without duplicates and without `primary` (the + /// command's main key in effect, which it already answers). + pub fn alternates(&self, id: &str, primary: Option<&str>) -> Vec { + let main = primary.and_then(normalize_shortcut); + let keys: Vec = match self.alternate_shortcuts.get(id) { + Some(list) => list.iter().filter_map(|s| normalize_shortcut(s)).collect(), + None => default_alternates(id).filter_map(normalize_shortcut).collect(), + }; + let mut out: Vec = Vec::with_capacity(keys.len()); + for k in keys { + if main.as_ref() != Some(&k) && !out.contains(&k) { + out.push(k); + } + } + out + } +} + +/// Most further keys one command may hold; Photoshop's own defaults use one. +pub const MAX_ALTERNATES: usize = 8; + +/// Validate and normalise a command's alternate keys: a list of shortcut strings (a single +/// string is a one-key list, `""` none). +pub fn alternate_keys(v: &Value) -> std::result::Result, String> { + let items: Vec<&Value> = match v { + Value::String(s) if s.is_empty() => Vec::new(), + Value::String(_) => vec![v], + Value::Array(a) => a.iter().collect(), + _ => return Err("alternate shortcuts are a list of shortcuts (e.g. [\"F3\"])".into()), + }; + if items.len() > MAX_ALTERNATES { + return Err(format!("at most {MAX_ALTERNATES} alternate shortcuts (got {})", items.len())); + } + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + let s = item.as_str().ok_or_else(|| format!("alternate shortcut `{item}` must be a string"))?; + let n = normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"))?; + if !out.contains(&n) { + out.push(n); + } + } + Ok(out) } // ------------------------------------------------------------------ shortcuts @@ -1448,6 +1473,24 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ ("tools.temporary.erase", "Erase with Brush or Pencil (hold)", "`"), ]; +/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key +/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the +/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace +/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In. +pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ + ("edit.cut", "F2"), + ("edit.copy", "F3"), + ("edit.paste", "F4"), + ("edit.fill", "Shift+Backspace"), + ("select.inverse", "Shift+F7"), + ("view.zoomIn", "Cmd++"), +]; + +/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]). +pub fn default_alternates(id: &str) -> impl Iterator + '_ { + ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc) +} + /// Every bindable id with its default: commands, then the temporary tools. fn bindable() -> impl Iterator)> { crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) @@ -1602,7 +1645,7 @@ impl Session { } let mut next = self.prefs().clone(); let mut color_changed = false; - if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "menus" | "toolbar") { + if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "alternateShortcuts" | "menus" | "toolbar") { // Whole section (or everything): merge the object key by key so each value is checked. let obj = value.as_object().ok_or_else(|| format!("`{path}` needs an object"))?; for (k, v) in obj { @@ -1611,7 +1654,12 @@ impl Session { color_changed |= self.set_pref("colorSettings", v.clone(), validate_color)?; continue; } - if v.is_object() && !p.starts_with("shortcuts") && !p.ends_with(".colors") && next.get(&p).is_some_and(|c| c.is_object()) { + if v.is_object() + && !p.starts_with("shortcuts") + && !p.starts_with("alternateShortcuts") + && !p.ends_with(".colors") + && next.get(&p).is_some_and(|c| c.is_object()) + { for (k2, v2) in v.as_object().into_iter().flatten() { next.set(&format!("{p}.{k2}"), v2.clone())?; } @@ -1787,20 +1835,29 @@ fn preferences_section(s: &mut Session, p: &Value) -> Result { /// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts. fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let cmd = "edit.keyboardShortcuts"; + // Every change lands at once, after all of it validated: a bad key leaves the set as it was. + let mut next = s.prefs().clone(); + let mut changed = false; if let Some(r) = p.get("reset") { match r { - Value::Bool(true) => s.edit_prefs(|p| p.shortcuts.clear()), - Value::Array(ids) => s.edit_prefs(|p| { + Value::Bool(true) => { + next.shortcuts.clear(); + next.alternate_shortcuts.clear(); + changed = true; + } + Value::Array(ids) => { for id in ids.iter().filter_map(Value::as_str) { - p.shortcuts.remove(id); + next.shortcuts.remove(id); + next.alternate_shortcuts.remove(id); } - }), + changed = true; + } _ => {} } } - // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` entries, matched by label - // (`crate::kys`); a key equal to the command's default restores the default, and keys given - // in `set` as well win over the file's. + // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` and `alternates` entries, + // matched by label (`crate::kys`); a key equal to the command's default restores the + // default, and keys given in `set` / `alternates` as well win over the file's. let imported; let mut import = None; let p = match p.get("importKys") { @@ -1814,6 +1871,13 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) }); } merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default()); + let mut alternates = serde_json::Map::new(); + for (id, keys) in &plan.alternate_keys { + let main = plan.set.get(id).map(String::as_str); + let default = Preferences::default().alternates(id, main); + alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) }); + } + alternates.extend(p.get("alternates").and_then(Value::as_object).cloned().unwrap_or_default()); import = Some(json!({ "name": set.name, "imported": plan.set.len(), @@ -1821,41 +1885,73 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, })); - let mut next = p.clone(); - next["set"] = Value::Object(merged); - imported = next; + let mut with_import = p.clone(); + with_import["set"] = Value::Object(merged); + with_import["alternates"] = Value::Object(alternates); + imported = with_import; &imported } None => p, }; - if let Some(m) = p.get("set").and_then(Value::as_object) { - let mut next = s.prefs().clone(); - for (id, v) in m { - if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) { + let empty = serde_json::Map::new(); + let set = match p.get("set") { + Some(Value::Object(m)) => m, + _ => &empty, + }; + let alts = match p.get("alternates") { + None | Some(Value::Null) => &empty, + Some(v) => v.as_object().ok_or_else(|| bad(cmd, "`alternates` maps command ids to lists of shortcuts"))?, + }; + if !set.is_empty() || !alts.is_empty() { + let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); + changed = true; + for (id, v) in set { + if !bindable().any(|(b, _)| b == id) && !allow_unknown { return Err(bad(cmd, format!("unknown command `{id}`"))); } next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; } - // Like Photoshop, a shortcut moved to a new command is taken away from the old one. + for (id, v) in alts { + if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; + } + // Like Photoshop, a shortcut moved to a new command is taken away from the old one, + // whether the old one held it as its main key or as an alternate (#3665). if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { - for (id, v) in m { - let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue }; - for (c, def) in bindable() { + let assigned: Vec<(&str, String)> = set + .iter() + .filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?))) + .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) + .collect(); + for (id, sc) in assigned { + // Shell commands (View › Zoom In) have alternates but no engine default. + let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None)); + for (c, def) in bindable().chain(shell) { // Never strip a command that this same call is assigning: // two entries for one key are a clash inside the call, which // the returned conflicts list reports (#719). Stripping // each other here unbound both silently. - if c == id || m.contains_key(c) { + if c == id || set.contains_key(c) || alts.contains_key(c) { continue; } - if next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { + let main = next.shortcut(c, def).map(str::to_string); + if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { next.shortcuts.insert(c.to_string(), String::new()); } + let held = next.alternates(c, main.as_deref()); + if held.contains(&sc) { + next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect()); + } } } } + } + if changed { s.edit_prefs(|p| *p = next); } let prefs = s.prefs(); @@ -1866,18 +1962,34 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) .chain(temporary) .filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f))) - .filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.0)) + .filter(|c| { + p.get("list").and_then(Value::as_bool).unwrap_or(false) + || filter.is_some() + || prefs.shortcuts.contains_key(c.0) + || prefs.alternate_shortcuts.contains_key(c.0) + }) .map(|(id, label, menu, def, hold)| { - let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)}); + let main = prefs.shortcut(id, def); + let defaults: Vec<&str> = default_alternates(id).collect(); + let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)}); + if !defaults.is_empty() { + v["defaultAlternates"] = json!(defaults); + } if hold { v["hold"] = json!(true); } v }) .collect(); - let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect(); - let conflicts: Vec = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); - let mut out = json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts}); + let mut bindings: Vec<(&str, String)> = Vec::new(); + for (id, def) in bindable() { + let main = prefs.shortcut(id, def); + bindings.extend(main.map(|m| (id, m.to_string()))); + bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k))); + } + let conflicts: Vec = + conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); + let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts}); if let Some(import) = import { out["import"] = import; } @@ -1898,6 +2010,9 @@ fn menus(s: &mut Session, p: &Value) -> Result { } } let (hide, show) = (ids("hide"), ids("show")); + if let Some(id) = hide.iter().find(|id| !MenuCustomization::hideable(id)) { + return Err(bad("edit.menus", format!("`{id}` can't be hidden"))); + } s.edit_prefs(|pr| { if p.get("reset").and_then(Value::as_bool) == Some(true) { pr.menus = MenuCustomization::default(); @@ -2006,7 +2121,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6b56cbc7ba..bf2fe52efd 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -49,6 +49,24 @@ fn canvas_dot_grid_can_be_disabled_persisted_and_reset() { assert!(s.prefs().interface.show_canvas_dots); } +/// UI-217-10: the Home screen's recent-files list can be switched off and the choice persists. +#[test] +fn home_recent_files_preference_defaults_on_persists_and_resets() { + let mut s = Session::new(); + assert!(s.prefs().interface.show_home_recent_files); + s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": false})).unwrap(); + assert!(!s.prefs().interface.show_home_recent_files); + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert!(!restored.prefs().interface.show_home_recent_files); + let mut legacy = Session::new(); + legacy.load_prefs_json(r#"{"interface": {"theme": "studio"}}"#).unwrap(); + assert!(legacy.prefs().interface.show_home_recent_files); + assert!(s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": 3})).is_err()); + s.execute("prefs.reset", json!({"path": "interface.showHomeRecentFiles"})).unwrap(); + assert!(s.prefs().interface.show_home_recent_files); +} + #[test] fn appearance_defaults_and_legacy_theme_migrate() { let p = Preferences::default(); @@ -397,6 +415,19 @@ fn menus_and_toolbar_customisation_persist() { assert_eq!(back.toolbar.hidden, vec!["Sponge".to_string()]); } +/// #5096: Edit › Menus can't hide the way back into itself, Preferences, Save or Exit. +#[test] +fn menus_refuse_to_hide_essential_items() { + let mut s = session(); + for id in ["edit.menus", "edit.keyboardShortcuts", "edit.preferences.general", "file.save", "file.saveAs", "file.exit"] { + assert!(s.execute("edit.menus", json!({"hide": ["edit.fade", id]})).is_err(), "{id}"); + assert!(s.prefs().menus.hidden.is_empty(), "{id}: a refused call changes nothing"); + } + // A preferences file that lists them anyway still shows them. + let menus = MenuCustomization { hidden: vec!["file.exit".into(), "edit.fade".into()], ..Default::default() }; + assert!(!menus.is_hidden("file.exit") && menus.is_hidden("edit.fade")); +} + #[test] fn preference_sections_are_commands() { let mut s = Session::new(); @@ -625,6 +656,168 @@ fn ruler_units_round_to_their_own_decimal_places() { assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333"); } +/// #2259: arrow-key nudges bundle into one History state by default, within a pause long enough +/// for a held key's auto-repeat delay; both are Tools preferences with a validated range. +#[test] +fn nudge_bundling_preferences() { + let p = Preferences::default(); + assert!(p.tools.bundle_nudges); + assert_eq!(p.tools.nudge_bundle_pause_ms, 1000); + // Preferences saved before these existed load with the defaults. + let old: Preferences = serde_json::from_value(json!({"tools": {"showTooltips": false}})).unwrap(); + assert!(old.tools.bundle_nudges && !old.tools.show_tooltips); + assert_eq!(old.tools.nudge_bundle_pause_ms, 1000); + + let mut s = session(); + assert_eq!(s.execute("prefs.get", json!({"path": "tools.bundleNudges"})).unwrap(), json!(true)); + s.execute("prefs.set", json!({"values": {"tools.bundleNudges": false, "tools.nudgeBundlePauseMs": 250}})).unwrap(); + assert!(!s.prefs().tools.bundle_nudges); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 250); + for edge in [100, 10_000] { + s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": edge})).unwrap(); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, edge); + } + for bad in [json!(0), json!(99), json!(10_001), json!(-1), json!(1.5e300), json!("long"), json!(null)] { + assert!(s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": bad.clone()})).is_err(), "{bad}"); + } + assert!(s.execute("prefs.set", json!({"path": "tools.bundleNudges", "value": "yes"})).is_err()); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 10_000, "a rejected value changes nothing"); + s.execute("prefs.reset", json!({"path": "tools"})).unwrap(); + assert!(s.prefs().tools.bundle_nudges); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 1000); +} + +/// #3665: Photoshop's second default keys (F2/F3/F4, Shift+F7, Cmd++) are a command's +/// alternates, beside the main key its menu shows. +#[test] +fn commands_have_photoshops_alternate_keys() { + let p = Preferences::default(); + let main = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!(main("edit.cut"), Some("Cmd+X")); + assert_eq!(p.alternates("edit.cut", main("edit.cut")), ["F2"]); + assert_eq!(p.alternates("edit.copy", main("edit.copy")), ["F3"]); + assert_eq!(p.alternates("edit.paste", main("edit.paste")), ["F4"]); + assert_eq!(main("select.inverse"), Some("Cmd+Shift+I")); + assert_eq!(p.alternates("select.inverse", main("select.inverse")), ["Shift+F7"]); + assert_eq!(p.alternates("edit.fill", main("edit.fill")), ["Shift+Backspace"]); + assert_eq!(p.alternates("view.zoomIn", Some("Cmd+=")), ["Cmd++"]); + assert!(p.alternates("edit.undo", main("edit.undo")).is_empty()); + // No alternate is another command's main key or another command's alternate. + let mut seen = std::collections::HashMap::new(); + for c in crate::command_specs() { + if let Some(sc) = c.shortcut.and_then(normalize_shortcut) { + seen.insert(sc, c.id); + } + } + for (id, sc) in ALTERNATE_SHORTCUTS { + let n = normalize_shortcut(sc).unwrap(); + assert!(seen.insert(n, id).is_none(), "{sc} of {id} is already bound"); + } + // A command's own main key is never repeated as its alternate. + assert!(p.alternates("edit.copy", Some("F3")).is_empty()); +} + +/// Settings files written before alternates existed still load, keeping their overrides and +/// gaining the default alternates. +#[test] +fn old_settings_without_alternates_load() { + let old: Preferences = serde_json::from_str(r#"{"shortcuts": {"edit.copy": "Cmd+K", "edit.undo": ""}}"#).unwrap(); + assert_eq!(old.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(old.alternate_shortcuts.is_empty()); + // Overriding the main key keeps the alternate (Photoshop edits each row on its own). + assert_eq!(old.alternates("edit.copy", old.shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); + // And a file with alternates round-trips. + let mut p = old.clone(); + p.set("alternateShortcuts.edit.copy", json!(["f9", "F9"])).unwrap(); + let back: Preferences = serde_json::from_value(p.to_json()).unwrap(); + assert_eq!(back.alternate_shortcuts.get("edit.copy"), Some(&vec!["F9".to_string()])); +} + +#[test] +fn alternate_shortcuts_set_reset_and_reject_bad_input() { + let mut s = session(); + // Through the command: a list replaces the defaults, [] removes them, null restores them. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9"]}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F9"]); + let r = s.execute("edit.keyboardShortcuts", json!({"filter": "edit.copy"})).unwrap(); + let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.copy").unwrap().clone(); + assert_eq!(row["alternates"], json!(["F9"])); + assert_eq!(row["defaultAlternates"], json!(["F3"])); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": []}})).unwrap(); + assert!(s.prefs().alternates("edit.copy", Some("Cmd+C")).is_empty()); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": null}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F3"]); + // Through prefs.set / prefs.reset by path. + s.execute("prefs.set", json!({"path": "alternateShortcuts.edit.cut", "value": "F10"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F10"]); + s.execute("prefs.reset", json!({"path": "alternateShortcuts.edit.cut"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F2"]); + // Reset all clears alternates too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.paste": []}})).unwrap(); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + assert!(s.prefs().alternate_shortcuts.is_empty()); + // Bad input is an error, not a panic, and changes nothing. + for bad in [ + json!({"alternates": {"edit.copy": "Cmd+Shift"}}), + json!({"alternates": {"edit.copy": [3]}}), + json!({"alternates": {"edit.copy": {"a": 1}}}), + json!({"alternates": {"edit.copy": vec!["F1"; MAX_ALTERNATES + 1]}}), + json!({"alternates": {"no.such.command": ["F9"]}}), + json!({"alternates": "F3"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", bad.clone()).is_err(), "{bad}"); + } + assert!(s.prefs().alternate_shortcuts.is_empty()); + // A bad key in the same call as a reset leaves every shortcut as it was. + s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert!(s.execute("edit.keyboardShortcuts", json!({"reset": true, "alternates": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert_eq!(s.prefs().shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": 7})).is_err()); +} + +/// A key given to another command is taken from the command that held it as an alternate +/// (Photoshop moves a shortcut rather than duplicating it), and kept duplicates are reported. +#[test] +fn moving_an_alternate_key_takes_it_from_its_holder() { + let mut s = session(); + // F2 is Cut's alternate; Rename Layer may take it (#2405). + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}, "removeConflicts": false})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["shortcut"] == "F2"), "{r}"); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}"); + assert!(s.prefs().alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(s.prefs().shortcut("edit.cut", Some("Cmd+X")), Some("Cmd+X"), "Cut keeps its main key"); + // An alternate assigned to another command takes a main key too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["Cmd+Z"]}})).unwrap(); + assert_eq!(s.prefs().shortcut("edit.undo", Some("Cmd+Z")), None); + // Overriding the main key keeps the alternates. + s.execute("edit.keyboardShortcuts", json!({"reset": true, "set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", s.prefs().shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); +} + +/// #3665: a `.kys` row listing a command's default and a function key keeps both; the old +/// import kept only the first key. +#[test] +fn kys_import_keeps_a_rows_second_key() { + let mut s = session(); + let xml = "\ + Cmd+CF3\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = s.execute("edit.keyboardShortcuts", json!({"importKys": xml})).unwrap(); + assert_eq!(r["import"]["alternates"], 2); + let p = s.prefs(); + assert_eq!(p.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+C")); + assert_eq!(p.alternates("edit.copy", Some("Cmd+C")), ["F3"]); + assert!(!p.alternate_shortcuts.contains_key("edit.copy"), "the default alternate needs no override"); + // A row without Cut's F2 removes it; Undo gains F1. + assert!(p.alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(p.alternates("edit.undo", Some("Cmd+Z")), ["F1"]); +} + /// #3296: like Photoshop, "Use Shift Key for Tool Switch" is on by default; a stored choice wins. #[test] fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index e6bff8eb6b..8da6cee8a0 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -4,6 +4,9 @@ //! library as one group named after the file, with the file's own folders nested inside it //! as Photoshop does (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in //! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`). +//! - `brush.presets.importImage` turns an image file (anything File › Open reads: PNG, JPEG, +//! TIFF, PSD…) into a sampled-tip brush preset, as Edit › Define Brush Preset does with a +//! selection: dark pixels paint, white and transparent ones don't (TOOL-213-12). //! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the //! Gradients panel as one group. //! @@ -115,6 +118,87 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings })) } +/// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a +/// selection), cropped to the painted extent. The composite is rendered in bands twice: once to +/// find the painted bounds, then only those, so a large mostly-white image never needs a +/// full-size buffer. +fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec, usize, usize)> { + let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0); + let mut bounds: Option = None; + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> { + let w = band.rect.width() as usize; + for (i, c) in band.px.iter().enumerate() { + if paint(*c) > 1e-4 { + let (x, y) = (band.rect.x0 + (i % w.max(1)) as i32, band.rect.y0 + (i / w.max(1)) as i32); + let px = photocraft_geom::Rect::new(x, y, x.saturating_add(1), y.saturating_add(1)); + bounds = Some(bounds.map_or(px, |b| b.union(&px))); + } + } + Ok(()) + }); + let area = bounds.ok_or_else(|| bad(cmd, "the image contains no paint (all white or transparent)"))?; + let max = crate::brush_cmds::MAX_TIP; + if area.width().max(area.height()) > max { + return Err(bad(cmd, format!("the painted part of the image is too large for a brush tip ({}×{}; max {max} px)", area.width(), area.height()))); + } + let (w, h) = (area.width() as usize, area.height() as usize); + let mut v = vec![0.0f32; w * h]; + let _ = photocraft_compose::render_bands(doc, area, 0, |band| -> std::result::Result<(), ()> { + let bw = band.rect.width() as usize; + for (i, c) in band.px.iter().enumerate() { + let (x, y) = ((band.rect.x0 - area.x0) as usize + i % bw.max(1), (band.rect.y0 - area.y0) as usize + i / bw.max(1)); + if let Some(out) = v.get_mut(y * w + x) { + *out = paint(*c); + } + } + Ok(()) + }); + Ok((v, w, h)) +} + +/// Brushes panel › Import Brushes… with an image file: a new sampled-tip preset named after the +/// file (never overwriting one), made the current brush unless `select` is false. +fn import_image(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.presets.importImage"; + let (bytes, stem) = file_bytes(p, cmd, "image file")?; + let file_name = match p.get("fileName") { + None => None, + Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`fileName` must be a non-empty string"))?), + }; + // The decoder picks the format by content, falling back to the name's extension. + let decode_name = p.get("path").and_then(Value::as_str).or(file_name).unwrap_or("image"); + let stem = if stem.is_empty() { + file_name.and_then(|n| std::path::Path::new(n).file_stem()).map(|s| s.to_string_lossy().trim().to_string()).unwrap_or_default() + } else { + stem + }; + let name = match p.get("name") { + None if stem.is_empty() => "Imported Brush".to_string(), + None => stem, + Some(v) => v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` must be a non-empty string"))?.to_string(), + }; + let doc = crate::file_cmds::import(decode_name, &bytes)?; + let (v, w, h) = image_paint(&doc, cmd)?; + let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?; + let taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); + let name = unique(&name, &taken); + s.tools.presets.push(photocraft_paint::BrushPreset { + name: name.clone(), + brush: brush.clone(), + builtin: false, + group: String::new(), + folder: Vec::new(), + captures: Default::default(), + }); + s.brush_presets_changed(); + if p.get("select").and_then(Value::as_bool).unwrap_or(true) { + s.tools.brush = brush; + s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); + } + Ok(json!({ "name": name, "width": tw, "height": th })) +} + /// Brush-file import command specs. pub fn specs() -> Vec { vec![ @@ -128,6 +212,16 @@ pub fn specs() -> Vec { run: import_abr, journal: true, }, + CommandSpec { + id: "brush.presets.importImage", + label: "Import Brush from Image…", + menu: &[], + shortcut: None, + params: r##"{"path":"image file (PNG, JPEG, TIFF, PSD…)"?,"data":base64 bytes?,"fileName":string? (with data: the file's name, for its format and the default preset name),"name":string?=file name,"select":bool=true (make it the current brush)} → {name, width, height}"##, + enabled: always, + run: import_image, + journal: true, + }, CommandSpec { id: "gradient.presets.importGrd", label: "Import Gradients…", @@ -362,6 +456,76 @@ mod tests { assert!(k.iter().all(|v| *v < 0.01), "{k:?}"); } + /// A 40×30 16-bit RGBA PNG: transparent, a white opaque band (no paint) and a black 10×6 block + /// at (5, 8) (full paint). + fn stamp_png16() -> Vec { + let mut px = vec![0u16; 40 * 30 * 4]; + for y in 0..30 { + for x in 0..40 { + let i = (y * 40 + x) * 4; + if (5..15).contains(&x) && (8..14).contains(&y) { + px[i + 3] = u16::MAX; + } else if y >= 20 { + px[i..i + 4].copy_from_slice(&[u16::MAX; 4]); + } + } + } + let img = photocraft_codecs::Image::from_u16(40, 30, photocraft_codecs::ChannelLayout::Rgba, &px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() + } + + #[test] + fn imports_an_image_as_a_brush_tip() { + use photocraft_paint::TipShape; + let mut s = Session::new(); + let data = photocraft_paint::tile::b64_encode(&stamp_png16()); + let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png"})).unwrap(); + assert_eq!(r, json!({"name": "Stamp", "width": 10, "height": 6}), "cropped to the dark block"); + let pr = s.tools.presets.iter().find(|p| p.name == "Stamp").unwrap(); + let TipShape::Sampled(tip) = &pr.brush.tip else { panic!("sampled tip expected") }; + assert_eq!((tip.width, tip.height), (10, 6)); + assert!((pr.brush.size - 10.0).abs() < 1e-6); + assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp"), "the imported brush is current"); + assert!(matches!(s.tools.brush.tip, TipShape::Sampled(_))); + // A second import keeps the first: a new name, and `select: false` leaves the brush alone. + let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png", "select": false})).unwrap(); + assert_eq!(r["name"], "Stamp 2"); + assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp")); + + // An 8-bit grayscale JPEG from disk: named after the file. + let px: Vec = (0..24 * 24).map(|i| if (8..16).contains(&(i % 24)) && (4..20).contains(&(i / 24)) { 0 } else { 255 }).collect(); + let img = photocraft_codecs::Image::from_u8(24, 24, photocraft_codecs::ChannelLayout::Gray, px).unwrap(); + let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap(); + let path = std::env::temp_dir().join(format!("pc-brush-tip-{}.jpg", std::process::id())); + std::fs::write(&path, jpeg).unwrap(); + let r = s.execute("brush.presets.importImage", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let r = r.unwrap(); + assert!(r["name"].as_str().unwrap().starts_with("pc-brush-tip-"), "{r}"); + let (w, h) = (r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()); + assert!((8..=10).contains(&w) && (16..=18).contains(&h), "JPEG ringing aside, the bar: {r}"); + } + + #[test] + fn image_brush_import_fails_cleanly() { + let mut s = Session::new(); + let white = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Gray, vec![255; 16]).unwrap(); + let white = photocraft_paint::tile::b64_encode(&photocraft_codecs::encode(&white, photocraft_codecs::Format::Png, &Default::default()).unwrap()); + let n = s.tools.presets.len(); + for p in [ + json!({}), + json!({"data": white}), + json!({"data": "@@"}), + json!({"data": photocraft_paint::tile::b64_encode(b"not an image")}), + json!({"data": white, "name": 3}), + json!({"data": white, "fileName": ""}), + json!({"path": "/nope/brush.png"}), + ] { + assert!(s.execute("brush.presets.importImage", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); + } + #[test] fn unique_names() { let taken = vec!["A".to_string(), "a 2".to_string()]; diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 5a72f781fc..6c722c62ef 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -83,6 +83,8 @@ pub struct PresetState { pub documents: Vec, /// File › New › Recent: the settings of documents recently created there, newest first. pub recent_documents: Vec, + /// File › Export › Export As: named export configurations. + pub export_presets: Vec, pub clone: clone_source::CloneSources, /// The user defaults "Make Default" in the Layer Style dialog saves /// (effect kind → param set); "Reset to Default" restores them. @@ -105,6 +107,7 @@ impl Default for PresetState { tool_presets: tools::builtin(), documents: Vec::new(), recent_documents: Vec::new(), + export_presets: Vec::new(), clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), @@ -135,6 +138,8 @@ struct Persisted { #[serde(default)] recent_documents: Option, #[serde(default)] + export_presets: Option, + #[serde(default)] custom_shapes: Option>, #[serde(default)] layer_defaults: Option>, @@ -153,6 +158,7 @@ impl PresetState { tool_presets: Some(json!(self.tool_presets)), documents: Some(json!(self.documents)), recent_documents: Some(json!(self.recent_documents)), + export_presets: Some(json!(self.export_presets)), custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), @@ -191,6 +197,9 @@ impl Session { if let Some(r) = p.recent_documents { st.recent_documents = crate::document_preset_cmds::load_recent(r); } + if let Some(e) = p.export_presets { + st.export_presets = crate::export_preset_cmds::load(e); + } if let Some(c) = p.custom_shapes { self.edit_state.custom_shapes = c; } diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index ecc812a51c..d6fc50aeea 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -30,10 +30,6 @@ fn mode(p: &Value) -> SelectionMode { /// A Color Range replacement is scoped to the selection that was active when it opened. /// Explicit add/subtract/intersect operations retain their normal selection-combine semantics. -fn point_in_selection(doc: &Document, x: i32, y: i32) -> bool { - doc.selection.as_ref().is_none_or(|selection| selection.sample_channel(x, y, 0) > 0.0) -} - fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionMode) { if m != SelectionMode::Replace { return; @@ -50,10 +46,17 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; let area = d.doc.bounds(); + // Match the decoders' raster budget before either the layer or composite path allocates. + let len = area.size().area(); + let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { + return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { - let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = usize::try_from(len).map_err(|_| EngineError::Other("the canvas is too large to sample for a selection".into()))?; + let mut px = crate::allocation::filled(len, [0.0f32; 4], "sampling pixels for a selection")?; surf.read_rgba_into(area, &mut px); Ok((area, px)) } @@ -146,14 +149,7 @@ fn parse_color(p: &Value) -> [f32; 3] { /// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point /// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples( - p: &Value, - key: &str, - area: Rect, - px: &[[f32; 4]], - allowed: &impl Fn(i32, i32) -> bool, - bad: &impl Fn(String) -> EngineError, -) -> Result> { +fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; let w = area.width() as usize; @@ -171,9 +167,6 @@ fn eyedropper_samples( let Some(&[r, g, b, _]) = q else { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); }; - if !allowed(xi, yi) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); } Ok(out) @@ -244,9 +237,6 @@ impl ColorRangeSamples { if !area.contains(x, y) { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); } - if mode(p) == SelectionMode::Replace && !point_in_selection(doc, x, y) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } let r = Rect::from_xywh(x, y, 1, 1); let pixel = if !b(p, "sampleAllLayers", true) { s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).and_then(|l| l.surface()).map(|surf| { @@ -370,10 +360,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result { let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let allowed = |x, y| selection_mode != SelectionMode::Replace || point_in_selection(doc, x, y); - let plus = eyedropper_samples(p, "points", area, &px, &allowed, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &allowed, &bad)?; + // Like Photoshop's, they may sample anywhere on the canvas: an active selection only + // limits the result (scope_color_range), not where colours are picked (#5244). + let plus = eyedropper_samples(p, "points", area, &px, &bad)?; + let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); (area, mask) @@ -770,6 +760,38 @@ mod tests { assert!(err.to_string().contains("detectFaces"), "{err}"); } + #[test] + fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { + let mut s = Session::new(); + s.add_document( + photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ), + None, + ); + for all_layers in [false, true] { + assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + } + assert!(!s.active().unwrap().history.can_undo()); + } + + #[test] + fn selection_sampling_allocation_failure_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.execute("select.similar", json!({"sampleAllLayers": false})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); @@ -829,15 +851,24 @@ mod tests { assert_eq!(coverage(&s, 30, 10), 0.0); } + #[test] + fn color_range_eyedropper_samples_anywhere_but_the_result_stays_in_the_selection() { + // #5244: as in Photoshop, a colour picked outside the active selection is accepted; only + // the result is limited to the selection. + let mut s = session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + let outside = json!({"points": [[30, 10]], "fuzziness": 0}); + assert!(ColorRangeSamples::new(&s, &outside).is_ok()); + s.execute("select.colorRange", outside).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "the red inside the selection is selected"); + assert_eq!(coverage(&s, 30, 10), 0.0, "the sampled red outside it stays out"); + assert_eq!(coverage(&s, 2, 2), 0.0, "white is not the sampled colour"); + } + #[test] fn color_range_eyedropper_respects_selection_but_add_can_extend_it() { let mut s = session(); s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); - let before = current_mask(&s.active().unwrap().doc).1; - let outside = json!({"points": [[30, 10]], "fuzziness": 0}); - assert!(s.execute("select.colorRange", outside.clone()).unwrap_err().to_string().contains("outside the active selection")); - assert!(ColorRangeSamples::new(&s, &outside).err().unwrap().to_string().contains("outside the active selection")); - assert_eq!(current_mask(&s.active().unwrap().doc).1, before); s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 0})).unwrap(); assert_eq!(coverage(&s, 10, 10), 1.0); assert_eq!(coverage(&s, 30, 10), 0.0); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..649a154419 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -827,21 +827,24 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id) = s.edit(label, |doc, _| { + let (parent, contents_id, changed) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; - for instance in instances(doc, contents_id) { + let changed = instances(doc, contents_id); + for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id)) + Ok((doc.id, contents_id, changed)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); - Ok(json!({"layer": id.0})) + // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the + // command once per copy (#4075). + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 83458f05a6..04063d3073 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -767,6 +767,38 @@ fn command_list_reports_enablement() { assert_eq!(find("layer.new.layer")["enabled"], false); } +/// The Elliptical and Single Row/Column Marquee are `select.rect` options: a command-list filter +/// for "ellipse" or "marquee" finds it through its keywords (#5060). +#[test] +fn command_list_filter_finds_the_marquees_by_keyword() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + let found = |needle: &str| -> Vec { + rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect() + }; + for needle in ["ellipse", "Elliptical Marquee", "marquee", "single row", "SELECT.RECT"] { + assert!(found(needle).contains(&"select.rect".to_string()), "{needle}: {:?}", found(needle)); + } + let rect = rows.iter().find(|c| c["id"] == "select.rect").unwrap(); + assert!(rect["keywords"].as_str().is_some_and(|k| k.contains("Elliptical Marquee")), "{rect}"); + // Commands without keywords don't get an empty field, and the filter still reads id and label. + assert!(rows.iter().find(|c| c["id"] == "file.new").unwrap().get("keywords").is_none()); + assert_eq!(found("gaussian blur"), ["filter.blur.gaussianBlur"]); + assert!(found("no command is called this").is_empty()); +} + +/// Every keyword entry names a real command, so a renamed id can't leave one behind. +#[test] +fn search_keywords_name_real_commands() { + for (id, words) in crate::commands::SEARCH_KEYWORDS { + assert!(crate::commands::find(id).is_some(), "keywords for unknown command {id}"); + assert!(!words.trim().is_empty(), "{id}"); + } + assert!(crate::commands::search_keywords("select.rect").is_some()); + assert!(crate::commands::search_keywords("no.such.command").is_none()); +} + /// With no document open, a document command says so instead of only `enabled: false` (#3664). #[test] fn command_list_tells_no_document_from_other_preconditions() { @@ -1123,6 +1155,47 @@ fn coalesced_edits_share_one_history_step() { assert!((l - 1.0).abs() < 1e-6, "{l}"); } +/// #2259: the commands behind the arrow-key nudges take the `coalesce` key, so a run of them is +/// one step an agent can drive too, and one Undo takes the whole run back. +#[test] +fn translate_and_transform_selection_coalesce_into_one_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 60})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let steps = |s: &Session| s.active().unwrap().history.past_len(); + let left = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().x0 + }; + let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds().x0; + + let base = steps(&s); + for _ in 0..5 { + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:1"})).unwrap(); + } + assert_eq!((steps(&s), left(&s)), (base + 1, 15), "five nudges, one step"); + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap(); + s.execute("layer.translate", json!({"dx": 1, "dy": 0})).unwrap(); + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap(); + assert_eq!(steps(&s), base + 4, "another key, an uncoalesced move and a repeated key each start a step"); + for _ in 0..3 { + assert!(s.undo()); + } + assert_eq!(left(&s), 15); + assert!(s.undo()); + assert_eq!(left(&s), 10, "Undo takes the run of five back at once"); + + let base = steps(&s); + for _ in 0..4 { + s.execute("select.transformSelection", json!({"dx": 1, "dy": 0, "coalesce": "nudge:3"})).unwrap(); + } + assert_eq!((steps(&s), sel(&s)), (base + 1, 14)); + assert!(s.undo()); + assert_eq!(sel(&s), 10); +} + #[test] fn type_edit_rerenders_cache_to_new_text() { let mut s = Session::new(); diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..9baa077608 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -12,7 +12,7 @@ use crate::{EngineError, Result, Session}; /// Longest timeline, in frames: every frame becomes a surface in a new video layer and a file in /// Render Video, so `duration` is capped well before it can exhaust memory or overflow (#1009). -const MAX_DURATION: usize = 1_000_000; +pub(crate) const MAX_DURATION: usize = 1_000_000; /// Highest frame rate; larger values only come from mistakes and become `inf` as `f32` (#1009). const MAX_FPS: f32 = 1000.0; diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index bcf97126cc..1c0153a42e 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -36,6 +36,23 @@ pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { } } +/// The box Free Transform opens on `layer`, and the Move tool's transform controls show (#5297). +/// With a selection that limits the transform it is the selection's own bounds, as in Photoshop: +/// the box and the marching ants are one rectangle, however little of the selection the layer +/// fills. Empty, like [`transform_bounds`], when the selection holds none of the layer. +pub fn transform_frame(doc: &Document, layer: &Layer) -> Rect { + let b = transform_bounds(doc, layer); + if moves_whole(layer) { b } else { framed_by_selection(doc, b) } +} + +/// `bounds` (what a selection left of a transform's content), or the selection's own bounds. +fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { + match &doc.selection { + Some(sel) if !bounds.is_empty() => sel.content_bounds(), + _ => bounds, + } +} + /// A group moves all its descendants, so its frame must not clip raster children to the selection. fn whole_layer_bounds(layer: &Layer) -> Rect { let content = match &layer.content { @@ -199,6 +216,12 @@ pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect { } } +/// The box Free Transform opens on a lone target: like [`transform_frame`], the selection's own +/// bounds when there is one (#5297). +pub fn target_frame(doc: &Document, surf: &Surface) -> Rect { + framed_by_selection(doc, target_bounds(doc, surf)) +} + /// The locks `id` inherits from the groups around it. pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks { doc.path_of(id).and_then(|p| Some(doc.locks_at(p.split_last()?.1))).unwrap_or_default() @@ -282,7 +305,12 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R } _ => { if let Some(surf) = l.surface() { - check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + // With a selection only the lifted pixels are warped (`split_selected`). + let src = match sel { + Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()), + None => surf.content_bounds(), + }; + check_budget(src, h, &format!("layer \"{}\"", l.name))?; } } } @@ -383,17 +411,20 @@ fn apply_transform( } _ => { if let Some(surf) = l.surface_mut() { - let src = surf.content_bounds(); *surf = match doc_sel { - // Only the selected pixels move: lift them, clear them, warp and paste back. + // Only the selected pixels move: lift them, clear them, warp and paste back. The + // lifted pixels' own bounds are the edge a stretch repeats (#3773). Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; + let moved = warp_surface_with(&lifted, lifted.content_bounds(), h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, + None => { + let src = surf.content_bounds(); + warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)? + } }; } } @@ -445,6 +476,11 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface let k = sel.sample_channel(x, y, 0); l[n - 1] *= k; r[n - 1] *= 1.0 - k; + // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the + // selected pixels' (what a stretch repeats at the edge, #3773). + if l[n - 1] <= 0.0 { + l.fill(0.0); + } if r[n - 1] <= 0.0 { r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); } @@ -1151,6 +1187,32 @@ mod tests { assert_eq!(transform_bounds(&st.doc, st.doc.layer(st.active_layer.unwrap()).unwrap()), Rect::new(10, 10, 15, 20)); } + /// #5297: the box Free Transform opens with a selection is the selection's own rectangle, + /// not the part of it the layer fills; type still moves whole, and a selection that holds none + /// of the layer leaves nothing to transform. + #[test] + fn the_frame_is_the_selections_own_bounds() { + let mut s = session(); + let frame = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (transform_frame(&st.doc, l), transform_bounds(&st.doc, l)) + }; + let content = Rect::new(10, 10, 30, 20); + assert_eq!(frame(&s), (content, content), "no selection: the content"); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 15, "height": 40})).unwrap(); + assert_eq!(frame(&s), (Rect::new(5, 5, 20, 45), Rect::new(10, 10, 20, 20))); + s.execute("select.rect", json!({"x": 60, "y": 60, "width": 10, "height": 10})).unwrap(); + assert!(frame(&s).0.is_empty(), "nothing of the layer is selected"); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 120})).unwrap(); + s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 36})).unwrap(); + let whole = active_bounds(&s); + s.execute("select.rect", json!({"x": whole.x0, "y": whole.y0, "width": whole.width() / 2, "height": whole.height()})).unwrap(); + assert_eq!(frame(&s), (whole, whole), "type moves whole"); + } + #[test] fn group_transform_ignores_selections_inside_its_children() { group_transform_with_selection(Rect::new(10, 10, 15, 15)); @@ -1249,6 +1311,93 @@ mod tests { assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + /// A 1 x 8 px column on a 256 x 256 canvas (#3773). + fn column_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(100, 100, 101, 108), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + fn opaque_green(s: &Session, x: i32, y: i32) -> bool { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (surf.sample_channel(x, y, 1) - 1.0).abs() < 1e-3 && (surf.sample_channel(x, y, 3) - 1.0).abs() < 1e-3 + } + + /// The "pixel stretch" (#3773): scales far past 1000% are taken, and the stretched column stays + /// solid along its whole length. + #[test] + fn a_one_pixel_column_stretches_5000_percent() { + let mut s = column_session(); + s.execute("edit.transform", json!({"quad": [[100, 100], [150, 100], [150, 108], [100, 108]]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(100, 100, 150, 108)); + for x in [100, 101, 125, 149] { + for y in 100..108 { + assert!(opaque_green(&s, x, y), "({x}, {y})"); + } + } + } + + /// The technique as done in Photoshop: select a 1 px column of an image and stretch it across. + #[test] + fn a_selected_column_stretches_solid_over_the_image() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(10, 0, 11, 256), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 256})).unwrap(); + s.execute("edit.transform", json!({"quad": [[10, 0], [250, 0], [250, 256], [10, 256]]})).unwrap(); + for x in [10, 11, 100, 200, 249] { + assert!(opaque_green(&s, x, 128), "x = {x}"); + } + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surf.sample_channel(250, 128, 0) > 0.999 && surf.sample_channel(9, 128, 0) > 0.999, "the image either side is untouched"); + } + + /// Free Transform keeps pixels moved off the canvas, as Photoshop does: an 8 x 8 layer moved + /// three canvas widths right and back comes back whole. + #[test] + fn a_layer_moved_far_off_the_canvas_and_back_keeps_its_pixels() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..8 { + for x in 0..8 { + surf.fill_rect(Rect::new(20 + x, 20 + y, 21 + x, 21 + y), &[x as f32 / 8.0, y as f32 / 8.0, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let pixels = |s: &Session| { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..8 * 8 * 4).map(|i| surf.sample_channel(20 + i / 4 % 8, 20 + i / 32, (i % 4) as usize)).collect::>() + }; + let before = pixels(&s); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(212, 20, 220, 28)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(20, 20, 28, 28)); + assert_eq!(pixels(&s), before); + } + #[test] fn relative_params_scale_and_rotate_about_the_frame_centre() { let mut s = Session::new(); diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..dcb184c675 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -11,6 +11,10 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +/// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the +/// quantised frame and palette while rendering, so the document canvas limit is not enough. +const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; + /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { let cur = doc.timeline.as_ref().map_or(0, |t| t.current); @@ -416,6 +420,13 @@ pub fn render_video_with( let mut base = (*st.doc).clone(); store(&mut base); let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); + // Loaded timelines bypass the duration validation used by timeline.create/setProps. + if frames > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!( + "the timeline is {frames} frames; Render Video is limited to {} frames", + crate::timeline_cmds::MAX_DURATION + ))); + } let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); @@ -429,6 +440,13 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { + // The whole canvas is staged as f32 RGBA for each frame; reject over-budget + // dimensions before an infallible allocation can abort the process. + if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { + return Err(EngineError::Other(format!( + "{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)" + ))); + } let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..c16c7f8e39 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -498,3 +498,25 @@ fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { grow_duration(&mut t, 12); assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); } + +#[test] +fn render_video_rejects_a_loaded_timeline_past_the_frame_cap() { + let mut s = session(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(usize::MAX, 30.0)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("overlong"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("limited to"), "{err}"); +} + +#[test] +fn render_video_rejects_gif_frames_too_large_to_stage() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9000, "height": 8000})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); +} diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/crop_content_aware.rs new file mode 100644 index 0000000000..f41d46a9b5 --- /dev/null +++ b/crates/engine/tests/crop_content_aware.rs @@ -0,0 +1,159 @@ +//! Crop tool › Fill: Content-Aware Fill (#3823). A rotated crop, or one that extends past the +//! canvas, fills the area the image doesn't cover from its surroundings instead of the background +//! colour, in the crop's one undo step; an unrotated crop inside the canvas is unaffected. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +const MODES: [(&str, u32); 5] = [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8)]; + +/// A 64 × 48 document whose Background is a light green, with black as the background colour (what +/// a plain crop puts in the uncovered area). +fn doc(mode: &str, depth: u32, background: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth, "background": background})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#c0e0a0", "background": "#000000"})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s +} + +fn layer(s: &Session) -> &photocraft_raster::Surface { + let d = &s.active().unwrap().doc; + d.layers[0].surface().unwrap() +} + +fn size(s: &Session) -> (u32, u32) { + let d = &s.active().unwrap().doc; + (d.size.width, d.size.height) +} + +fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < tol) +} + +/// Every pixel of the cropped layer is the image's colour: no black (or transparent) area left. +fn assert_all_image(s: &Session, want: [f32; 4], what: &str) { + let (w, h) = size(s); + let surf = layer(s); + for y in 0..h as i32 { + for x in 0..w as i32 { + let px = surf.rgba(x, y); + assert!(close(px, want, 0.06), "{what}: ({x}, {y}) is {px:?}, want {want:?}"); + } + } +} + +fn crop(s: &mut Session, mut p: Value, content_aware: bool) -> photocraft_engine::Result { + p["contentAware"] = json!(content_aware); + s.execute("image.crop", p) +} + +#[test] +fn rotated_crop_fills_the_corners_in_every_mode() { + for (mode, depth) in MODES { + let frame = json!({"x": 0, "y": 0, "width": 64, "height": 48, "angle": 10}); + let mut plain = doc(mode, depth, "white"); + let want = layer(&plain).rgba(32, 24); + crop(&mut plain, frame.clone(), false).unwrap(); + assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}: a plain rotated crop leaves black corners"); + + let mut s = doc(mode, depth, "white"); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, frame, true).unwrap(); + assert_eq!(size(&s), (64, 48), "{mode} {depth}"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode} {depth}: one undo step"); + assert_all_image(&s, want, &format!("{mode} {depth} rotated")); + } +} + +#[test] +fn extending_crop_fills_the_added_canvas_in_every_mode() { + for (mode, depth) in MODES { + let frame = json!({"x": -8, "y": -6, "width": 80, "height": 60}); + let mut plain = doc(mode, depth, "white"); + let want = layer(&plain).rgba(32, 24); + crop(&mut plain, frame.clone(), false).unwrap(); + assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}"); + + let mut s = doc(mode, depth, "white"); + crop(&mut s, frame, true).unwrap(); + assert_eq!(size(&s), (80, 60), "{mode} {depth}"); + assert_all_image(&s, want, &format!("{mode} {depth} extended")); + } +} + +/// Without a Background the active pixel layer is filled: no transparent strip is left. +#[test] +fn extending_crop_fills_the_active_layer_without_a_background() { + let mut s = doc("rgb", 16, "transparent"); + let want = layer(&s).rgba(32, 24); + crop(&mut s, json!({"x": 0, "y": -10, "width": 64, "height": 58}), true).unwrap(); + assert_all_image(&s, want, "transparent document"); +} + +/// Undo takes the crop and the fill back in one step. +#[test] +fn undo_restores_crop_and_fill_in_one_step() { + let mut s = doc("rgb", 8, "white"); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + crop(&mut s, json!({"x": -4, "y": -4, "width": 40, "height": 40, "angle": -6}), true).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(size(&s), (64, 48)); + let after = layer(&s); + let orig = before.layers[0].surface().unwrap(); + for y in 0..48 { + for x in 0..64 { + assert_eq!(after.rgba(x, y), orig.rgba(x, y), "({x}, {y})"); + } + } +} + +/// `contentAware: false` (or absent), and a crop that uncovers nothing, are the plain crop. +#[test] +fn nothing_uncovered_or_off_is_the_plain_crop() { + for (frame, ca) in [ + (json!({"x": 5, "y": 4, "width": 30, "height": 20}), true), + (json!({"x": -5, "y": -4, "width": 80, "height": 60}), false), + (json!({"x": 20, "y": 15, "width": 20, "height": 14, "angle": 4}), true), + ] { + let mut a = doc("rgb", 16, "white"); + let mut b = doc("rgb", 16, "white"); + a.execute("image.crop", frame.clone()).unwrap(); + crop(&mut b, frame.clone(), ca).unwrap(); + assert_eq!(size(&a), size(&b), "{frame}"); + let (w, h) = size(&a); + for y in 0..h as i32 { + for x in 0..w as i32 { + assert_eq!(layer(&a).rgba(x, y), layer(&b).rgba(x, y), "{frame} ({x}, {y})"); + } + } + } +} + +/// Bad params and an unfillable crop fail without changing the document. +#[test] +fn bad_params_and_unfillable_crops_fail_cleanly() { + let frame = json!({"x": -4, "y": 0, "width": 70, "height": 48}); + for v in [json!("yes"), json!(1), json!([true]), json!({"on": true})] { + let mut s = doc("rgb", 8, "white"); + let past = s.active().unwrap().history.past_len(); + let mut p = frame.clone(); + p["contentAware"] = v.clone(); + assert!(s.execute("image.crop", p).is_err(), "{v}"); + assert_eq!(size(&s), (64, 48), "{v}"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{v}"); + } + // Entirely off the canvas: nothing of the image to sample from (both paths). + for p in [json!({"x": 500, "y": 500, "width": 20, "height": 20}), json!({"x": 500, "y": 500, "width": 20, "height": 20, "angle": 30})] { + let mut s = doc("rgb", 8, "white"); + let past = s.active().unwrap().history.past_len(); + let err = crop(&mut s, p.clone(), true).unwrap_err(); + assert!(err.to_string().contains("sample"), "{p}: {err}"); + assert_eq!(size(&s), (64, 48), "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p}"); + } +} diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/duplicate_variables.rs new file mode 100644 index 0000000000..48d197b51e --- /dev/null +++ b/crates/engine/tests/duplicate_variables.rs @@ -0,0 +1,146 @@ +//! Variable definitions must continue targeting their layers after a copied document gets +//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer}; +use photocraft_engine::Session; +use serde_json::json; + +fn scene() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ids = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut photo = Layer::raster("photo", fmt); + photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0])); + let badge = Layer::raster("badge", fmt); + let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() })); + let (p, b, t) = (photo.id, badge.id, title.id); + doc.layers.push(photo); + doc.layers.push(badge); + doc.layers.push(title); + Ok((p, b, t)) + }) + .unwrap(); + (s, ids.0, ids.1, ids.2) +} + +fn red_png(dir: &str) -> String { + let doc = Document::with_background( + "red", + Size::new(4, 4), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::rgb(1.0, 0.0, 0.0), + ); + let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let path = format!("{dir}/replacement.png"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn copied_layer(s: &Session, name: &str) -> LayerId { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id +} + +fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) { + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + {"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + {"variable": "photo", "kind": "pixels", "value": png}, + ]}, + ]}), + ) + .unwrap(); +} + +#[test] +fn duplicated_documents_keep_variables_targeting_the_copied_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + s.execute(command, json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let new_photo = copied_layer(&s, "photo"); + let new_badge = copied_layer(&s, "badge"); + let new_title = copied_layer(&s, "title"); + assert_ne!(new_photo, photo, "{command}: layers must get new identities"); + let targets: Vec = doc.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers"); + + let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap(); + assert_eq!(r["applied"], "A"); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy"); + match &doc.layer(new_title).unwrap().content { + LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"), + _ => panic!("not a text layer"), + } + let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0); + assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}"); + + // The source document still targets its own layers and is untouched. + s.set_active(0); + let src = &s.active().unwrap().doc; + let src_targets: Vec = src.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched"); + assert!(src.layer(badge).unwrap().visible); + assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9); + } +} + +#[test] +fn dangling_variable_targets_stay_dangling_after_duplication() { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, "unused.png"); + // A def may already refer to a deleted layer before duplication. + let missing = LayerId(u64::MAX); + s.edit("Break one def", |doc, _| { + doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing; + Ok(()) + }) + .unwrap(); + s.execute("image.duplicate", json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap(); + assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id"); + let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap(); + assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap"); +} + +#[test] +fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer"); + assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers"); + assert!(d.variables.data_sets.is_empty()); + assert_eq!(d.variables.active, None); + + s.set_active(0); + assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved"); +} diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/smart_duplicates.rs index 12f5ec5486..554fd23f0f 100644 --- a/crates/engine/tests/smart_duplicates.rs +++ b/crates/engine/tests/smart_duplicates.rs @@ -4,7 +4,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject}; use photocraft_engine::Session; use photocraft_engine::smart_cmds::{decode_source, source_bytes}; use photocraft_geom::Rect; -use serde_json::json; +use serde_json::{Value, json}; fn session(depth: u64) -> (Session, u64, u64) { let mut s = Session::new(); @@ -195,3 +195,104 @@ fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() { s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap(); assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]); } + +/// A `.pcraft` file whose contents are one solid colour, for Replace Contents. +fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, rgba); + let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id())); + std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap(); + path +} + +/// The node named `name` in `document.inspect`'s layer tree, searched through groups. +fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> { + layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) }) +} + +/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so +/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which +/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count, +/// and Replace Contents lists every layer it changed. +#[test] +fn inspect_and_replace_contents_report_shared_instances() { + let (mut s, original, duplicate) = session(8); + let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap(); + s.edit("name and group", |doc, _| { + for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] { + doc.layer_mut(LayerId(id)).unwrap().name = name.into(); + } + // An instance inside a group counts too. + let copy = doc.remove(LayerId(duplicate)).unwrap(); + doc.layers.push(Layer::group("Copies", vec![copy])); + Ok(()) + }) + .unwrap(); + let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap(); + let mut s = Session::new(); + s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone()); + assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]); + assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]); + assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]); + assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers"); + + let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap()); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + assert_eq!(changed, if o < d { [o, d] } else { [d, o] }); + assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]); + for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] { + assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents"); + } +} + +/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object +/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents. +#[cfg(feature = "corpus")] +#[test] +fn photoshop_placed_copies_import_as_shared_instances() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone(); + file.layers_mut().push(placed); + let mut s = Session::new(); + s.add_document(photocraft_io::psd_to_document(&file).0, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect(); + assert_eq!(copies.len(), 2); + assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]); + assert_eq!(copies[0]["smartContents"]["instances"], 2); + for copy in &copies { + assert_eq!(copy["smartSource"]["kind"], "embedded"); + assert!(copy["smartSource"]["uuid"].as_str().is_some()); + } + let mut ids: Vec = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect(); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + ids.sort_unstable(); + assert_eq!(changed, ids); + for id in ids { + assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]); + let cache = smart(&s, id).cache.as_ref().unwrap(); + let bounds = cache.content_bounds(); + assert!(!bounds.is_empty(), "instance {id} must have rendered pixels"); + assert_eq!( + cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2), + [0.0, 1.0, 0.0, 1.0], + "instance {id} must refresh its imported cache" + ); + } +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 51d6183baa..95d45fafb9 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -617,6 +617,15 @@ impl Loader<'_> { } } } + // Variable targets follow their layers through id remapping; a dangling one keeps a + // non-colliding placeholder so it stays a missing-layer skip rather than driving an + // unrelated layer (#4969). With preserved ids the map is the identity and defs stay as saved. + let mut variables = m.variables.clone(); + if !self.preserve_ids { + for vd in &mut variables.defs { + vd.layer = self.id_map.get(&vd.layer.0).copied().unwrap_or(LayerId(u64::MAX - vd.layer.0.min(u64::MAX / 2))); + } + } let id = if self.preserve_ids { DocId(m.id) } else { DocId::fresh() }; Ok(Document { id, @@ -641,7 +650,7 @@ impl Loader<'_> { patterns, color_table: m.color_table.clone(), duotone: m.duotone.clone(), - variables: m.variables.clone(), + variables, timeline: m.timeline.clone(), file_encoding: m.file_encoding, layer_comps, diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 8b74a00c4b..f78e93947e 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -5,7 +5,7 @@ use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; -use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef}; use photocraft_format::*; use photocraft_raster::Rgba8Image; @@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } + +#[test] +fn variables_follow_layer_id_remapping() { + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + doc.variables.defs = vec![ + VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement }, + VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility }, + VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility }, + ]; + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + // Preserved ids keep every definition exactly as saved, dangling ones included. + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + // Remapped ids carry the definitions to the copied layers; a dangling target stays + // dangling and must not collide with a live layer (#4969). + let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); + let defs = &back.variables.defs; + assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint")); + assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner")); + let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; + assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1a6b60fca3..0e76736013 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -46,7 +46,12 @@ impl Rect { Self { x0, y0, x1, y1 } } pub fn from_xywh(x: i32, y: i32, w: u32, h: u32) -> Self { - Self::new(x, y, x.saturating_add(w as i32), y.saturating_add(h as i32)) + // Widen before adding: `w as i32` wraps negative for widths above `i32::MAX`, which + // would turn the far edge backwards (an empty, inverted rect) instead of clamping it + // to `i32::MAX` the way `saturating_add` alone would suggest. + let x1 = (i64::from(x) + i64::from(w)).min(i64::from(i32::MAX)) as i32; + let y1 = (i64::from(y) + i64::from(h)).min(i64::from(i32::MAX)) as i32; + Self::new(x, y, x1, y1) } pub fn from_size(size: Size) -> Self { Self::from_xywh(0, 0, size.width, size.height) @@ -117,7 +122,9 @@ impl TileCoord { Self::new(x.div_euclid(TILE_SIZE), y.div_euclid(TILE_SIZE)) } pub fn rect(&self) -> Rect { - Rect::from_xywh(self.tx * TILE_SIZE, self.ty * TILE_SIZE, TILE_SIZE as u32, TILE_SIZE as u32) + // `tx`/`ty` can come from a file (this type is deserialized), so saturate instead of + // overflowing (a debug-build panic / release-build wrap) on extreme coordinates. + Rect::from_xywh(self.tx.saturating_mul(TILE_SIZE), self.ty.saturating_mul(TILE_SIZE), TILE_SIZE as u32, TILE_SIZE as u32) } } @@ -258,6 +265,25 @@ mod tests { assert_eq!(grown.y0, i32::MIN); } + #[test] + fn from_xywh_clamps_huge_sizes_instead_of_wrapping() { + // Widths above `i32::MAX` used to wrap negative through `w as i32`, producing an + // empty inverted rect instead of clamping the far edge to `i32::MAX`. + assert_eq!(Rect::from_xywh(0, 0, u32::MAX, 10), Rect::new(0, 0, i32::MAX, 10)); + assert_eq!(Rect::from_xywh(-100, 5, u32::MAX, u32::MAX), Rect::new(-100, 5, i32::MAX, i32::MAX)); + assert_eq!(Rect::from_xywh(10, 20, i32::MAX as u32 + 1, 40).width(), i32::MAX as u32 - 10); + // Ordinary sizes are unchanged. + assert_eq!(Rect::from_xywh(10, 20, 30, 40), Rect::new(10, 20, 40, 60)); + } + + #[test] + fn tile_rect_saturates_on_extreme_coords() { + // Tile coords are deserialized from files; `tx * TILE_SIZE` used to overflow. + let r = TileCoord::new(i32::MAX, i32::MIN).rect(); + assert_eq!((r.x0, r.y0), (i32::MAX, i32::MIN)); + assert_eq!(TileCoord::new(2, -3).rect(), Rect::new(512, -768, 768, -512)); + } + #[test] fn width_and_height_do_not_overflow_on_extreme_edges() { // Inverted extreme rect: empty, so 0 (x1 - x0 used to overflow and panic). diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index e9d1b370ca..2b9c1bbdf4 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -473,6 +473,17 @@ fn posterize(v: f32, n: f32) -> f32 { return floor(bin * 255.0 / (n - 1.0)) / 255.0; } +// compose::adjust::posterize_in at 16 bits: bins of the 16-bit sample, landing on 8-bit levels +// through a 16-bit step. Every product and sum here is a whole number below 2^24, exact in f32; +// 16-bit layers reach the GPU as half floats, though, so a sample within their precision of a +// bin's edge can land in the neighbouring bin (the CPU, which exports, is exact). +fn posterize16(v: f32, n: f32) -> f32 { + let x = floor(clamp(v, 0.0, 1.0) * 32768.0 + 0.5); + let bin = min(floor(x * n / 32768.0), n - 1.0); + let step = floor(bin * 32768.0 / (n - 1.0)); + return floor((step * 255.0 + 16384.0) / 32768.0) / 255.0; +} + // Document pixel being adjusted (for ordered dither). var adj_px: vec2; @@ -580,49 +591,37 @@ fn tone_channel(row: i32, v: f32, identity: f32) -> f32 { return lut(row, v); } -// Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor. -// Mirrors compose::adjust::modern_brightness. -fn mbright(v: f32, b: f32) -> f32 { - if (b == 0.0) { return v; } - let s = pow(1.375, b / 50.0); - var p: f32; - if (b >= 0.0) { p = max(4.5 - 0.013 * b, 2.0); } else { p = 5.0 - 0.072 * b; } - let vv = clamp(v, 0.0, 1.0); - return clamp(s * vv + (1.0 - s) * pow(vv, p), 0.0, 1.0); -} - -// Modern Contrast curve: symmetric cubic-Hermite S pivoting at 0.5. -// Mirrors compose::adjust::modern_contrast. -fn mcontrast(v: f32, ct: f32) -> f32 { - if (ct == 0.0) { return v; } - let k = ct / 128.0; - let e = 1.0 - k; let m = 1.0 + k; - let vv = clamp(v, 0.0, 1.0); - var t: f32; - if (vv <= 0.5) { t = vv / 0.5; } else { t = (1.0 - vv) / 0.5; } - let h = e * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + m * 0.5 * (t * t * t - t * t); - if (vv <= 0.5) { return h; } - return 1.0 - h; -} - fn adjust(c: vec3) -> vec3 { + let hdr = (op.flags & F_HDR) != 0u; let p0 = op.p0; let p1 = op.p1; let p2 = op.p2; let p3 = op.p3; switch op.kind { case 1: { return 1.0 - c; } // Invert - case 2: { // Threshold (p0.x = 8-bit level) - return vec3(select(0.0, 1.0, round_half_up(gray(c) * 255.0) >= p0.x)); + case 2: { // Threshold (compose::adjust::threshold_white) + // p0.x = 8-bit level; p0.y = 1: 8-bit levels, 2: 16-bit samples against p0.z, 0: float. + // Sums of 30 R + 59 G + 11 B over whole samples are exact in f32. + var white = lum(c) * 255.0 >= p0.x; + if p0.y > 0.5 { + let q = select(32768.0, 255.0, p0.y < 1.5); + let v = floor(clamp(c, vec3(0.0), vec3(1.0)) * q + 0.5); + let s = 30.0 * v.r + 59.0 * v.g + 11.0 * v.b; + white = select(s >= 100.0 * p0.z, s > 100.0 * p0.x - 50.0, p0.y < 1.5); + } + return vec3(select(0.0, 1.0, white)); } - case 3: { return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); } - case 4: { return clamp((c + p0.x - p0.z) * p0.y + p0.z + p0.w, vec3(0.0), vec3(1.0)); } // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around compose::adjust::LEGACY_PIVOT (p0.z) - case 5: { // B/C modern - let b = p0.x; let ct = p0.y; - return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct)); + case 3: { // Posterize (p0.x levels; p0.y = 1: 16-bit) + if p0.y > 0.5 { return vec3(posterize16(c.r, p0.x), posterize16(c.g, p0.x), posterize16(c.b, p0.x)); } + return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); + } + case 4: { // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around the pivot (p0.z); 32-bit unclipped + let v = (c + p0.x - p0.z) * p0.y + p0.z + p0.w; + if (hdr) { return v; } + return clamp(v, vec3(0.0), vec3(1.0)); } case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure - case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves + case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); } case 8: { // Hue/Saturation let hsl = rgb_to_hsl(c); @@ -678,13 +677,17 @@ fn adjust(c: vec3) -> vec3 { l = clamp(l, vec3(0.0), vec3(p0.w)); return vec3(t_encode(l.r, p0.z), t_encode(l.g, p0.z), t_encode(l.b, p0.z)); } - case 10: { // Channel mixer - let r = clamp(dot(p0.xyz, c) + p0.w, 0.0, 1.0); + case 10: { // Channel mixer (32-bit unclipped) + let lo = select(0.0, -F32_MAX, hdr); + let hi = select(1.0, F32_MAX, hdr); + let r = clamp(dot(p0.xyz, c) + p0.w, lo, hi); if (p3.x > 0.5) { return vec3(r); } - return vec3(r, clamp(dot(p1.xyz, c) + p1.w, 0.0, 1.0), clamp(dot(p2.xyz, c) + p2.w, 0.0, 1.0)); + return vec3(r, clamp(dot(p1.xyz, c) + p1.w, lo, hi), clamp(dot(p2.xyz, c) + p2.w, lo, hi)); } case 11: { // Photo filter // compose::adjust::photo_filter_matrix (rows p0..p2) in linear light, then SetLum. + // 32-bit samples are linear already and stay unclipped. + if (hdr) { return vec3(dot(p0.xyz, c), dot(p1.xyz, c), dot(p2.xyz, c)); } let g = p3.y; let lin = vec3(t_decode(c.r, g), t_decode(c.g, g), t_decode(c.b, g)); var f = vec3(t_encode(dot(p0.xyz, lin), g), t_encode(dot(p1.xyz, lin), g), t_encode(dot(p2.xyz, lin), g)); @@ -703,12 +706,13 @@ fn adjust(c: vec3) -> vec3 { if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; } let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0); if (p1.z > 0.5) { + if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1 return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint } return vec3(g); } - case 13: { // Gradient map - var t = gray(c); + case 13: { // Gradient map (driven by the W3C luminosity) + var t = lum(c); if (p0.x > 0.5) { t = 1.0 - t; } var o = vec3(lut(0, t), lut(1, t), lut(2, t)); if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 223d4ed983..de6ca3e56e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -24,7 +24,9 @@ //! staged uploads and evicted pages stay bounded. //! - **Stack reuse.** A complete root stack unit can checkpoint its backdrop into bounded chunk //! textures. Exact COW snapshots invalidate the lower prefix; edits above it restore those -//! pixels and execute only the remaining passes. Groups and clipping units stay indivisible. +//! pixels and execute only the remaining passes, capturing on the way the highest checkpoint +//! below the edit, so repeating it (a duplicate or paste and its undo) skips that much more. +//! Groups and clipping units stay indivisible. //! //! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to //! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape @@ -75,6 +77,11 @@ pub const FX_BUDGET: usize = 1536 << 20; pub const PAGE: u32 = 2048; /// GPU memory resident layer pages may hold before the least recently used are evicted. pub const RESIDENT_BUDGET: u64 = 4 << 30; +/// GPU memory pattern textures may hold before the least recently painted are dropped (those the +/// current frame paints with always stay). +pub const PATTERN_BUDGET: u64 = 256 << 20; +/// A pattern texture no frame has painted with for this many frames is dropped. +pub const PATTERN_IDLE_FRAMES: u64 = 240; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so /// a refresh of a huge document never holds all its uploads in staging memory at once. const FLUSH_BYTES: u64 = 512 << 20; @@ -183,6 +190,12 @@ struct CachedPattern { format: PixelFormat, default_bits: Vec, texture: Tex, + /// The document that last painted with it: closing that document frees the texture (#4971). + doc: DocId, + /// [`Compositor::frame`] when it last painted. + last_used: u64, + /// GPU bytes of the texture (RGBA32F). + bytes: u64, } struct Resident { @@ -423,6 +436,10 @@ pub struct Compositor { /// The colour mode of the document being encoded: patterns paint in it /// (`photocraft_compose::pattern::pattern_rgba`). pattern_mode: photocraft_color::ColorMode, + /// The document being encoded, which owns the patterns it paints with. + pattern_doc: DocId, + /// GPU memory pattern textures may hold ([`PATTERN_BUDGET`] by default). + pattern_budget: u64, /// Texture side limit the paging is planned for (the device's, or smaller in tests). max_dim: u32, /// The device's real texture side limit. @@ -670,6 +687,8 @@ impl Compositor { temps: Vec::new(), patterns: HashMap::new(), pattern_mode: photocraft_color::ColorMode::Rgb, + pattern_doc: DocId(0), + pattern_budget: PATTERN_BUDGET, max_dim: 0, device_max: device.limits().max_texture_dimension_2d, page: PAGE, @@ -791,6 +810,17 @@ impl Compositor { self.residents.values().map(Resident::bytes).sum() } + /// GPU memory held by cached pattern textures (bytes). + pub fn pattern_cache_bytes(&self) -> u64 { + self.patterns.values().map(|p| p.bytes).sum() + } + + /// Set the GPU memory pattern textures may hold (default [`PATTERN_BUDGET`]). It takes effect + /// at the end of the next refresh. + pub fn set_pattern_budget(&mut self, bytes: u64) { + self.pattern_budget = bytes; + } + /// Whether `doc` can be composited on the GPU. Documents and layers larger than the texture /// limit are fine: surfaces are stored in page-sized textures and effect maps of regions /// larger than the limit are built per page cell. @@ -865,14 +895,22 @@ impl Compositor { pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); self.fx.retain(|_, e| e.doc != doc); + self.patterns.retain(|_, p| p.doc != doc); self.prefix.forget(doc); } /// [`Self::forget_doc`] for every document `keep` rejects: what the compositor holds for /// documents that were closed, however their canvas entry went (#4529). pub fn retain_docs(&mut self, keep: impl Fn(DocId) -> bool) { - let mut gone: Vec = - self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).filter(|d| !keep(*d)).collect(); + let mut gone: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .filter(|d| !keep(*d)) + .collect(); gone.sort_by_key(|d| d.0); gone.dedup(); for d in gone { @@ -880,9 +918,17 @@ impl Compositor { } } - /// Documents the compositor holds pages, effect maps or a lower-stack composite for. + /// Documents the compositor holds pages, effect maps, pattern textures or a lower-stack + /// composite for. pub fn held_docs(&self) -> Vec { - let mut docs: Vec = self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).collect(); + let mut docs: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .collect(); docs.sort_by_key(|d| d.0); docs.dedup(); docs @@ -994,6 +1040,7 @@ impl Compositor { let plan = plan(doc)?; self.check_fx(doc, &plan)?; self.pattern_mode = doc.pixel_format().mode; + self.pattern_doc = doc.id; let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() }; if region.is_empty() { return Ok(stats); @@ -1093,9 +1140,31 @@ impl Compositor { self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id)); stats.evicted += self.evict().0; self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240); + self.evict_patterns(); Ok(stats) } + /// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames, + /// then the least recently painted ones over the pattern budget. Never one this frame painted + /// with: it would only be uploaded again on the next frame. + fn evict_patterns(&mut self) { + let frame = self.frame; + self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES); + let mut total = self.pattern_cache_bytes(); + if total > self.pattern_budget { + let mut lru: Vec<(u64, String, u64)> = + self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect(); + lru.sort_unstable(); + for (_, key, bytes) in lru { + if total <= self.pattern_budget { + break; + } + self.patterns.remove(&key); + total = total.saturating_sub(bytes); + } + } + } + /// Keep resident pages within the resident budget and the effect cache within /// its budget, least recently used first. Never drops what the current cell uses, nor /// whole-region effect maps drawn this frame. Returns how many textures were dropped and @@ -1235,7 +1304,10 @@ impl Compositor { if chunks.is_empty() { return; } - let first_pass = checkpoint.filter(|_| chunks.iter().all(|c| self.prefix.contains(*c))).map_or(0, |c| c.pass_end); + // Chunks may hold different checkpoints: bind from the lowest one any of them resumes at. + let first_pass = checkpoint + .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) + .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); // Chunk pool. @@ -1305,7 +1377,7 @@ impl Compositor { for (ci, c) in chunks.iter().enumerate() { let mut resume = 0; - if let Some(cp) = checkpoint + if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c)) && let Some((dst, _)) = self.pool.get(cp.slot as usize) && self.prefix.copy_out(encoder, *c, dst) { @@ -1330,11 +1402,12 @@ impl Compositor { return; } } - if resume == 0 + // Also after resuming from a lower checkpoint: the chunk moves up to this one. + if resume < i && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let scissor = match p.clip { Some(clip) => { @@ -1400,11 +1473,11 @@ impl Compositor { pass.set_bind_group(1, bg1[i].as_ref(), &[]); pass.draw(0..3, 0..1); } - if resume == 0 + if resume < plan.passes.len() && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len()) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let (t, v) = &self.pool[plan.root as usize]; sink(encoder, ChunkOut { rect: *c, texture: t, view: v }); @@ -1579,11 +1652,14 @@ impl Compositor { let format = pat.surface.format(); // Unstored tiles read the default pixel, interpreted in the source format. let default_bits: Vec = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect(); - if let Some(cached) = self.patterns.get(&key) + let (doc, last_used) = (self.pattern_doc, self.frame); + if let Some(cached) = self.patterns.get_mut(&key) && cached.fingerprint == fp && cached.format == format && cached.default_bits == default_bits { + cached.doc = doc; + cached.last_used = last_used; return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); @@ -1597,7 +1673,8 @@ impl Compositor { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); let v = t.view.clone(); - self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t }); + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; + self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 48abbc0930..58ce6e4299 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -652,11 +652,15 @@ impl<'a> Planner<'a> { let opacity = layer.opacity * layer.fill_opacity; let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect(); - // Below 100% fill a pass-through group renders isolated (as the CPU compositor does). + // Below 100% fill a pass-through group renders isolated (as the CPU compositor does), and + // so does one that is the base of a clipping group and holds an adjustment layer. + let clip_base_adjusts = !visible_clipped.is_empty() + && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !has_effects(layer) + && !clip_base_adjusts { let before = self.retain(backdrop); let mut after = self.stack(&g.children, backdrop)?; @@ -1593,11 +1597,19 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf match adj { Adjustment::Invert => (1, p, None), Adjustment::Threshold { level } => { - p[0][0] = (level * 255.0).round(); + // The level, the depth's split (compose::adjust::threshold_white) and the level in 16 bits. + let t = (level * 255.0).round(); + p[0] = match depth { + photocraft_color::SampleType::U8 => [t, 1.0, 0.0, 0.0], + photocraft_color::SampleType::U16 => [t, 2.0, (t * 32768.0 / 255.0).round(), 0.0], + photocraft_color::SampleType::F32 => [t, 0.0, 0.0, 0.0], + }; (2, p, None) } Adjustment::Posterize { levels } => { + // The levels, and the 16-bit rule (compose::adjust::posterize_in). p[0][0] = (*levels).clamp(2, 255) as f32; + p[0][1] = if depth == photocraft_color::SampleType::U16 { 1.0 } else { 0.0 }; (3, p, None) } Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { @@ -1605,14 +1617,16 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; // Brightness before contrast when it is raised, after it when lowered (compose::adjust). let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) }; - p[0] = [pre, k, photocraft_compose::adjust::LEGACY_PIVOT, post]; + // 32-bit documents pivot at 127 of 255 and don't clip (the shader checks F_HDR). + let pivot = if depth == photocraft_color::SampleType::F32 { 127.0 / 255.0 } else { photocraft_compose::adjust::LEGACY_PIVOT }; + p[0] = [pre, k, pivot, post]; (4, p, None) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { - // Modern B/C: the shader rebuilds the curves from raw slider values (see compose.wgsl - // `mbright`/`mcontrast`, mirroring compose::adjust::modern_brightness/modern_contrast). - p[0] = [*brightness, *contrast, 0.0, 0.0]; - (5, p, None) + // Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel, + // through the Levels / Curves kernel. + let row = to_row(&adjust::modern_lut(*brightness, *contrast)); + (7, p, Some(vec![row; 3])) } Adjustment::Exposure { exposure, offset, gamma } => { p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())]; @@ -1655,9 +1669,14 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (9, p, None) } Adjustment::ChannelMixer { matrix, monochrome } => { - p[0] = matrix[0]; - p[1] = matrix[1]; - p[2] = matrix[2]; + // compose::adjust: 32-bit documents cut each percentage to 1/1024, count 255/512 of + // the constant and don't clip (the shader checks F_HDR). + let hdr = depth == photocraft_color::SampleType::F32; + let cut = |v: f32| if hdr { (v * 1024.0).trunc() / 1024.0 } else { v }; + let rows = matrix.map(|r| [cut(r[0]), cut(r[1]), cut(r[2]), if hdr { r[3] * 255.0 / 512.0 } else { r[3] }]); + p[0] = rows[0]; + p[1] = rows[1]; + p[2] = rows[2]; p[3][0] = if *monochrome { 1.0 } else { 0.0 }; (10, p, None) } @@ -1813,10 +1832,15 @@ mod tests { for mode in [ColorMode::Rgb, ColorMode::Grayscale] { assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}"); } - // CMYK documents invert, posterize and threshold their inks, on the CPU. - let inks = matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. }); + // CMYK documents invert, posterize, threshold and brighten their inks, on the CPU. + let inks = !matches!(adj, Adjustment::GradientMap { .. }); assert_eq!(adjustment_on_gpu(adj, ColorMode::Cmyk), !inks, "{adj:?} in CMYK"); } + // CMYK Hue/Saturation works on the inks (on the CPU); Colorize stays on the screen colour. + let ranges = photocraft_doc::adjust::HueRange::defaults(); + let hue = |colorize| Adjustment::HueSaturation { hue: 30.0, saturation: 10.0, lightness: 0.0, colorize, ranges: ranges.clone() }; + assert!(!adjustment_on_gpu(&hue(false), ColorMode::Cmyk) && adjustment_on_gpu(&hue(true), ColorMode::Cmyk)); + assert!(adjustment_on_gpu(&hue(false), ColorMode::Rgb)); // Screen-colour adjustments stay on the GPU in Lab documents. assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab)); } diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 0312a25fa2..34c70916e0 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -6,6 +6,13 @@ //! every frame, and caching those would fill the budget with entries that never hit again. //! A partial rectangle is served as a sub-rect copy of the whole chunk it falls into, and a //! capture merges into the chunk it overlaps, so every hit lands on the same stable entry. +//! +//! Each chunk remembers the checkpoint it holds. A render captures at the highest checkpoint +//! below its edit, also where it resumed from a lower cached one, so the cache follows the edit +//! up the stack chunk by chunk: repeating an edit high in the stack (a duplicate or paste and its +//! undo) then skips everything below it, and chunks a refresh doesn't reach keep serving their +//! own, lower prefix meanwhile (#5286). A chunk moved by a partial capture holds only that +//! rectangle afterwards: the rest of it misses once, like any fresh entry. use std::collections::HashMap; use photocraft_doc::{Document, Layer}; @@ -21,11 +28,17 @@ pub(crate) const MAX_BYTES: u64 = 512 << 20; pub(crate) struct Chunk { tex: Tex, covered: Vec, + /// The checkpoint the covered pixels hold. + checkpoint: Checkpoint, + bytes: u64, } pub(crate) struct Prefix { context: Option, + /// The root layers of the last prepared render, all of them: a chunk stays valid while every + /// render since its capture left the layers below its checkpoint as they were. layers: Vec, + /// Where renders capture: the highest useful checkpoint below the latest edit. checkpoint: Option, chunks: HashMap, bytes: u64, @@ -90,6 +103,7 @@ impl Prefix { self.context.as_ref().map(|d| d.id) } + /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at. pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option { if self.budget == 0 { return None; @@ -97,39 +111,51 @@ impl Prefix { // Keeping every non-layer field makes new document inputs invalidate automatically. // Tile data is Arc-shared; shared Tile equality does not scan its pixel bytes. let mut context = doc.clone(); - let mut layers = std::mem::take(&mut context.layers); + let layers = std::mem::take(&mut context.layers); let same_context = self.context.as_ref() == Some(&context); - let changed = self.layers.iter().zip(&layers).position(|(a, b)| a != b).or_else(|| (layers.len() < self.layers.len()).then_some(layers.len())); - let old = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); - let checkpoint = old.or_else(|| { - if same_context && let Some(changed) = changed { - plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied() - } else { + // The lowest root layer this render changes since the last one (a layer added or removed + // at the top changes from there on). + let changed = self + .layers + .iter() + .zip(&layers) + .position(|(a, b)| a != b) + .or_else(|| (layers.len() != self.layers.len()).then_some(layers.len().min(self.layers.len()))); + if same_context { + let mut freed = 0u64; + self.chunks.retain(|_, c| { + let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint); + if !keep { + freed = freed.saturating_add(c.bytes); + } + keep + }); + self.bytes = self.bytes.saturating_sub(freed); + } else { + self.clear(); + } + let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); + let checkpoint = kept.or_else(|| match changed { + // Just below the edit; nothing when it is at the bottom. + Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), + _ => { let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1); let last = useful.next().copied(); useful.next().copied().or(last) } }); - if old.is_none() { - self.clear(); - } - let Some(checkpoint) = checkpoint else { - // Remember bottom-layer edits even when no useful prefix survives: otherwise - // every other edit would populate and immediately discard the default checkpoint. - self.context = Some(context); - self.layers = layers; - return None; - }; - layers.truncate(checkpoint.layer_end); + // Remember bottom-layer edits even when no useful prefix survives: otherwise every other + // edit would populate and immediately discard the default checkpoint. self.context = Some(context); self.layers = layers; - self.checkpoint = Some(checkpoint); - Some(checkpoint) + self.checkpoint = checkpoint; + checkpoint } - /// Whether every pixel of `rect` is already cached in its whole grid chunk. - pub(crate) fn contains(&self, rect: Rect) -> bool { - self.covered_by(rect).is_some() + /// The checkpoint the cached pixels of `rect` hold, when every one of them is cached in its + /// whole grid chunk. + pub(crate) fn restore_point(&self, rect: Rect) -> Option { + self.covered_by(rect).map(|(chunk, _)| chunk.checkpoint) } /// The whole chunk and the source offset that serve `rect`, when it is fully covered. @@ -160,7 +186,8 @@ impl Prefix { /// Merges the rendered `rect` (document coordinates) into its whole grid chunk, admitting /// the chunk while space remains. Replacing entries on every miss would make a full /// refresh larger than the cache evict its own next hits. - pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture) { + /// A chunk holding another (lower) checkpoint starts over with this capture. + pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture, checkpoint: Checkpoint) { if rect.is_empty() { return; } @@ -174,9 +201,15 @@ impl Prefix { let tex = Tex::new(device, "pc_compose_prefix", cell.width(), cell.height(), src.format(), wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); self.bytes = self.bytes.saturating_add(cell_bytes); - self.chunks.insert(cell, Chunk { tex, covered: Vec::new() }); + self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } let Some(chunk) = self.chunks.get_mut(&cell) else { return }; + if chunk.checkpoint != checkpoint { + // The render restored this chunk's old pixels earlier in the same encoder, so + // overwriting them in place is ordered after that copy. + chunk.covered.clear(); + chunk.checkpoint = checkpoint; + } // A whole-chunk render replaces the coverage; a partial rectangle lands at its offset // inside the cell, beside whatever earlier strokes put there. if rect == cell { diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index c61aee9b67..84d3d0e937 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -59,6 +59,14 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { black: Vec::new(), pencil: false, }, + // 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant. + Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }, + Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, + Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false }, + Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0; 4], [0.0; 4]], monochrome: true }, + Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true }, + Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false }, + Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }, ] { let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F); @@ -259,6 +267,93 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { } } +/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes +/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and +/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every +/// depth. +#[test] +fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::new(&g.device); + uncached.set_composite_cache_budget(0); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth); + for seed in 1..=6 { + doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + } + let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats { + let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); + let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly"); + stats + }; + exact(&mut g, &doc, "populate"); + // An edit low in the stack puts the cache there. + doc.layers[1].opacity = 0.6; + assert_eq!(exact(&mut g, &doc, "low edit").prefix_hits, 0); + let low = doc.clone(); + let copy = doc.layers[4].duplicate(); + doc.layers.insert(5, copy); + let first = exact(&mut g, &doc, "duplicate"); + assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); + let undo = exact(&mut g, &low, "undo"); + assert!(undo.prefix_hits > 0, "{depth:?}"); + assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + let redo = exact(&mut g, &doc, "redo"); + assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}"); + // An edit below the moved cache still invalidates it. + doc.layers[2].opacity = 0.4; + assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}"); + } +} + +/// A small refresh above the cache moves only the chunk it renders, which then holds just that +/// rectangle; the other chunks keep serving their lower checkpoint, and a full render afterwards +/// resumes each chunk from its own (#5286). Every render matches an uncached compositor exactly. +#[test] +fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + uncached.set_texture_limit(PAGED_LIMIT); + uncached.set_composite_cache_budget(0); + let mut doc = base_doc(600, 300); + for seed in 2..=5 { + doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)); + } + let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats { + let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, rect).unwrap(); + assert_eq!(cached, cold, "{what}: reuse preserves the GPU accumulator exactly"); + let cpu = photocraft_compose::render(doc, rect); + assert!(worst_diff(&cpu.px, &cached).0 <= TOL, "{what}: GPU and CPU differ"); + stats + }; + render(&mut g, &doc, doc.bounds(), "populate"); + doc.layers[1].opacity = 0.5; + let low = render(&mut g, &doc, doc.bounds(), "low edit"); + assert_eq!(low.prefix_hits, 0); + // A copy near the top, refreshed in one chunk only. + let copy = doc.layers[3].duplicate(); + doc.layers.insert(4, copy); + let small = render(&mut g, &doc, Rect::new(10, 10, 60, 60), "partial duplicate refresh"); + assert_eq!(small.prefix_hits, 1, "{small:?}"); + let full = render(&mut g, &doc, doc.bounds(), "full refresh after the partial one"); + assert_eq!(full.prefix_hits, full.chunks - 1, "only the partly moved chunk misses: {full:?}"); + let again = render(&mut g, &doc, doc.bounds(), "full refresh, all moved"); + assert_eq!(again.prefix_hits, again.chunks); + assert!(again.prefix_passes_skipped > full.prefix_passes_skipped, "{full:?} {again:?}"); + // Mixed checkpoints: a bottom-half refresh moves the chunks it covers whole further up + // (an edit at the top), the top half keeps the previous checkpoint and still hits. + doc.layers.last_mut().unwrap().opacity = 0.7; + let bottom = render(&mut g, &doc, Rect::new(0, 256, 600, 300), "bottom strip"); + assert_eq!(bottom.prefix_hits, bottom.chunks); + let moved = render(&mut g, &doc, Rect::new(0, 0, 512, 256), "whole top chunks"); + assert_eq!(moved.prefix_hits, moved.chunks); + let mixed = render(&mut g, &doc, doc.bounds(), "full refresh, mixed checkpoints"); + assert_eq!(mixed.prefix_hits, mixed.chunks, "{mixed:?}"); +} + #[test] fn root_checkpoints_keep_clipping_groups_and_adjustments_whole() { let Some(mut g) = gpu() else { return }; @@ -576,6 +671,9 @@ fn adjustment_layers() { let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true }; d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf))); check(&mut g, &d, &format!("photo filter {depth:?}")); + // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. + d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); + check(&mut g, &d, &format!("threshold {depth:?}")); } for adj in adjustments() { for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] { @@ -2135,6 +2233,71 @@ fn pattern_cache_tracks_default_pixels() { } } +/// A document that paints one `side` x `side` pattern named `name`. +fn pattern_fill_doc(name: &str, side: u32) -> Document { + let mut d = Document::new(name, Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let pat = Pattern::new(name, photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]), side, side); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + d +} + +/// #4971: a pattern texture belongs to the document that last painted with it, and closing that +/// document frees it. Before, pattern textures were kept until the device was lost. +#[test] +fn closed_documents_release_their_pattern_textures() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let one = 4 * 4 * 16; + assert_eq!(g.comp.pattern_cache_bytes(), 0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); + g.comp.forget_doc(a.id); + assert_eq!(g.comp.pattern_cache_bytes(), one, "closing a document frees its pattern"); + assert_eq!(g.comp.held_docs(), vec![b.id]); + g.comp.retain_docs(|_| false); + assert_eq!(g.comp.pattern_cache_bytes(), 0); + check(&mut g, &a, "the pattern is uploaded again after being forgotten"); +} + +/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and +/// by age. What the current frame paints with is never dropped. +#[test] +fn pattern_textures_are_evicted_over_budget_and_when_idle() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let mut plain = base_doc(8, 6); + plain.id = photocraft_doc::DocId(a.id.0 + 2); + let one = 4 * 4 * 16; + // Room for one pattern and a half: painting the second drops the first. + g.comp.set_pattern_budget(one + one / 2); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "the least recently painted pattern went"); + // A budget below a single pattern still keeps the one being painted. + g.comp.set_pattern_budget(0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b again"); + assert_eq!(g.comp.pattern_cache_bytes(), one); + // Idle: frames that paint no pattern age it out. + g.comp.set_pattern_budget(photocraft_gpu::PATTERN_BUDGET); + for _ in 0..photocraft_gpu::PATTERN_IDLE_FRAMES - 1 { + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + } + assert_eq!(g.comp.pattern_cache_bytes(), one, "still within the idle limit"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "dropped after {} idle frames", photocraft_gpu::PATTERN_IDLE_FRAMES); + check(&mut g, &b, "the pattern is uploaded again after being evicted"); +} + #[test] fn pattern_cache_tracks_source_format_and_cow_tiles() { let Some(mut g) = gpu() else { return }; @@ -2436,3 +2599,22 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { assert!(worst_diff(&cpu.px, &out).0 <= TOL); } } + +// A pass-through group holding an adjustment renders isolated when layers clip to it, on the GPU +// as on the CPU (psd-tools passthrough_clipping_mask_adjustment). +#[test] +fn pass_through_clipping_base_with_an_adjustment() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(32, 24); + d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(0, 0, 32, 24), 41, 1.0)); + let mut grp = Layer::group( + "g", + vec![noise_layer("in", PixelFormat::RGBA8, Rect::new(4, 4, 20, 20), 42, 0.5), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))], + ); + grp.blend = BlendMode::PassThrough; + d.layers.push(grp); + let mut clip = noise_layer("clip", PixelFormat::RGBA8, Rect::new(8, 0, 28, 16), 43, 0.3); + clip.clipped = true; + d.layers.push(clip); + check(&mut g, &d, "pass-through clipping base with an adjustment"); +} diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index ae3aff5a74..7c88fc1b9c 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -5,6 +5,9 @@ //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows //! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. //! +//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, +//! which is how phone cameras write it: see [`icc_for_item`]. +//! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. //! @@ -163,7 +166,7 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) return (None, None, None); }; let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + let icc = icc_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), @@ -172,6 +175,28 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries +/// none. +/// +/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a +/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and +/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo +/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is +/// the composed image's profile. +/// +/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap +/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no +/// override for it, so that half has to be fixed upstream. +fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { + let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + if own.is_some() { + return own; + } + // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. + let (_, tiles) = ctx.grid(item).ok()??; + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +240,200 @@ mod tests { assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } + + // A grid whose colour description lives on its tiles, not on the grid item. + // + // The fixtures below are synthetic containers with no coded data at all: resolving a profile + // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so + // a mistake in the builder cannot look like a passing fix. + + /// Stands in for a profile. `colr` hands its payload back without parsing it. + const TILE_ICC: &[u8] = b"tile-profile"; + const GRID_ICC: &[u8] = b"grid-profile"; + + const GRID_ID: u16 = 1; + const PICTURE_ID: u16 = 2; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + /// A box whose first four payload bytes are a version and 24 bits of flags. + fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) + } + + fn hdlr() -> Vec { + let mut p = vec![0; 4]; + p.extend_from_slice(b"pict"); + p.extend_from_slice(&[0; 12]); + p.push(0); + full(b"hdlr", 0, &p) + } + + fn infe(id: u16, item_type: &[u8; 4]) -> Vec { + let mut p = id.to_be_bytes().to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(item_type); + p.push(0); + full(b"infe", 2, &p) + } + + fn iref_dimg(from: u16, to: u16) -> Vec { + let mut p = from.to_be_bytes().to_vec(); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&to.to_be_bytes()); + full(b"iref", 0, &bx(b"dimg", &p)) + } + + fn colr_prof(icc: &[u8]) -> Vec { + let mut p = b"prof".to_vec(); + p.extend_from_slice(icc); + bx(b"colr", &p) + } + + fn ispe(w: u32, h: u32) -> Vec { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full(b"ispe", 0, &p) + } + + fn ipma(entries: &[(u16, &[u8])]) -> Vec { + let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); + for (item, assoc) in entries { + p.extend_from_slice(&item.to_be_bytes()); + p.push(u8::try_from(assoc.len()).unwrap()); + p.extend_from_slice(assoc); + } + full(b"ipma", 0, &p) + } + + /// `item`'s bytes live in `idat` (construction method 1) at offset 0. + fn iloc_in_idat(item: u16, length: u32) -> Vec { + let mut p = vec![0x44, 0x00]; + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&item.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(&length.to_be_bytes()); + full(b"iloc", 1, &p) + } + + fn file(meta: &[u8]) -> Vec { + let mut ftyp = b"heic".to_vec(); + ftyp.extend_from_slice(&0u32.to_be_bytes()); + ftyp.extend_from_slice(b"mif1heic"); + let mut out = bx(b"ftyp", &ftyp); + out.extend_from_slice(&full(b"meta", 0, meta)); + out + } + + /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. + /// + /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the + /// property essential, as macOS writes it. + fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); + if let Some(icc) = grid_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + grid_assoc.push(0x80 | index); + } + if let Some(icc) = tile_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + tile_assoc.push(0x80 | index); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); + + let mut items = 2u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(GRID_ID, b"grid")); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + // Version 0, flags 0, one row, one column, 64x64 output. + const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); + meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); + file(&meta) + } + + /// One coded picture rather than a derivation, with `icc` on the item itself. + fn picture_file(icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let mut assoc = vec![0x81]; + if let Some(icc) = icc { + ipco.extend_from_slice(&colr_prof(icc)); + assoc.push(0x82); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); + + let mut items = 1u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + file(&meta) + } + + fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { + heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") + } + + #[test] + fn a_grid_inherits_its_tiles_icc_profile() { + let bytes = grid_file(None, Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + let primary = ctx.meta.primary; + assert_eq!(primary, u32::from(GRID_ID)); + let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); + assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); + assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); + assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); + + assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); + } + + #[test] + fn a_grids_own_profile_wins_over_its_tiles() { + let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + } + + #[test] + fn an_unlabelled_grid_has_no_profile() { + let bytes = grid_file(None, None); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } + + #[test] + fn a_coded_picture_resolves_its_own_profile() { + let labelled = picture_file(Some(GRID_ICC)); + let ctx = ctx_of(&labelled); + assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + + let bare = picture_file(None); + let ctx = ctx_of(&bare); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } } diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 53cfd205ff..549ac40970 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -290,6 +290,7 @@ impl Mapper<'_> { control, fade_steps: num(d, "fStp").map_or(25, |v| v.clamp(1.0, 9999.0) as u32), minimum: pct(d, "Mnm ").unwrap_or(0.0).clamp(0.0, 1.0), + ..Dynamic::default() } } diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..501e331af9 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -177,6 +177,29 @@ fn v6_settings_map_onto_brush_sections() { } } +#[test] +fn dual_brush_tip_spacing_takes_precedence_over_wrapper_spacing() { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Close-spaced dual brush")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 40.0)))) + .with( + "dualBrush", + Value::Descriptor( + Descriptor::new("dualBrush") + .with("useDualBrush", Value::Boolean(true)) + .with("Spcn", prc(100.0)) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 12.0)).with("Spcn", prc(5.0)))), + ), + ); + let bytes = write_v6(2, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Dual").unwrap(); + assert_eq!(imported.presets.len(), 1); + let brush = &imported.presets[0].brush; + assert!(brush.dual_brush.enabled); + assert!((brush.dual_brush.spacing - 0.05).abs() < 1e-6, "the tip's 5% spacing must not be replaced with the wrapper's 100%"); + assert!((brush.spacing - 0.25).abs() < 1e-6, "main brush spacing is independent"); +} + #[test] fn missing_pattern_and_tip_are_reported() { let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere"))); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 6e82478389..d4bc312690 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -280,7 +280,8 @@ fn parse_gradient_map(d: &[u8]) -> Option { 3 => (Some(d.get(4..8)?), 8), _ => return None, }; - at += 4 + be32(d, at)? as usize * 2; + // The gradient's name (UTF-16 units); checked, as `len * 2` overflows a 32-bit `usize`. + at = (be32(d, at)? as usize).checked_mul(2)?.checked_add(at + 4)?; let n = usize::from(be16(d, at)?); at += 2; let mut stops = Vec::with_capacity(n); diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 5474d00310..7e25feb984 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -225,8 +225,8 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result> = vec![None; info.len()]; + // Each chosen part (deduplicated above) decodes once; groups come from their part's named + // channels. let mut layers: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut gray_only = true; @@ -234,14 +234,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result img.clone(), - None => { - let img = codecs::decode_exr_part(bytes, part.index, &opts)?; - part_pixels[part.index] = Some(img.clone()); - img - } - }; + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; if img.sample_type() == SampleType::F16 { f16_seen = true; } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 5512a55c01..54fe96090d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -16,13 +16,15 @@ const BAND_BYTES: usize = 32 << 20; /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - if codecs::detect(bytes) == Some(Format::Tiff) { + // Detected once; every step below branches on it. + let format = codecs::detect(bytes); + if format == Some(Format::Tiff) { return import_tiff_page(name, bytes, None); } - let img = codecs::decode(bytes)?; + let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop // (#3802, #4804). - let palette = match codecs::detect(bytes) { + let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), _ => None, @@ -34,15 +36,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { r.document.mode = ColorMode::Indexed; r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } - r.document.file_encoding = file_encoding(bytes); - if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + r.document.file_encoding = file_encoding_as(format, bytes); + if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear // gray so they display and convert correctly. let d = &mut r.document; - if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { + if d.icc_profile.is_none() && matches!(format, Some(Format::OpenExr | Format::Hdr)) { d.icc_profile = match d.mode { ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()), ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()), @@ -68,8 +70,13 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document /// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its /// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { + file_encoding_as(codecs::detect(bytes), bytes) +} + +/// [`file_encoding`] for `bytes` already detected as `format`. +fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::FileEncoding { let mut e = photocraft_doc::FileEncoding::default(); - match codecs::detect(bytes) { + match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index b369341b99..8af6638aa8 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,9 +231,9 @@ impl<'a> TileFile<'a> { for &(x, y, data) in &self.tiles { ctl.check().map_err(|_| IoError::Cancelled)?; let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; - let planar = match flag { - 1 => lzf_decompress(payload, expected)?, - 0 if payload.len() == expected => payload.to_vec(), + let planar: std::borrow::Cow<'_, [u8]> = match flag { + 1 => lzf_decompress(payload, expected)?.into(), + 0 if payload.len() == expected => payload.into(), 0 => return Err(invalid("raw tile has the wrong size")), f => return Err(invalid(format!("unknown tile compression flag {f}"))), }; diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index 6634735ef0..e200b875ea 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -136,9 +136,11 @@ pub(crate) fn source_size_in_blocks<'a>( } /// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's -/// directory), PNG (`IHDR`) and JPEG (the frame header); any other image is decoded flat by -/// `photocraft-codecs`. It never imports a document: a layered TIFF can carry PSD layer data -/// whose `lnkD` holds another file, so a full import here would nest without bound (#2465). +/// directory), PNG (`IHDR`) and JPEG (the frame header); WebP, GIF, BMP, EXR and the rest of +/// `photocraft_codecs::probe_size`'s formats from their decoder's header; anything else (or a +/// header that probe cannot read) is decoded flat by `photocraft-codecs`. It never imports a +/// document: a layered TIFF can carry PSD layer data whose `lnkD` holds another file, so a full +/// import here would nest without bound (#2465). pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { let be32 = |at: usize| bytes.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let size = |w: u32, h: u32| (f64::from(w), f64::from(h)); @@ -151,10 +153,7 @@ pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { } [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => be32(16).zip(be32(20)).map(|(w, h)| size(w, h)), [0xFF, 0xD8, ..] => jpeg_size(bytes), - _ => photocraft_codecs::decode(bytes).ok().map(|img| { - let (w, h) = img.dimensions(); - size(w, h) - }), + _ => photocraft_codecs::probe_size(bytes).or_else(|_| photocraft_codecs::decode(bytes).map(|img| img.dimensions())).ok().map(|(w, h)| size(w, h)), } } @@ -408,9 +407,11 @@ mod tests { fn source_size_probe_reads_headers() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; let img = Image::new(7, 5, ChannelLayout::Rgb, SampleType::U8).unwrap(); - for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp] { + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp, Format::WebP, Format::Gif, Format::OpenExr, Format::Tga] { let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + // A header cut short neither panics nor invents a size (the decode fallback fails too). + assert_eq!(source_size_probe(&bytes[..10]), None, "{format:?} truncated"); } let mut psd = b"8BPS\0\x01".to_vec(); psd.extend([0; 6]); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 47861dbcc3..4fb952ba25 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1746,9 +1746,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer + // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right + // after the last kept marker, else first. + let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { - (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None }, - (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None }, + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); @@ -1865,6 +1869,31 @@ mod tests { assert_eq!(unique, reloaded_ids); } + /// #5068: Photoshop won't open a file whose `Lr32` comes before the `Mt32` marker it keeps for + /// a 32-bit document's merged transparency (verified in Photoshop 2026 on psd-tools' + /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. + #[test] + fn layer_block_follows_a_kept_merged_transparency_marker() { + for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), (*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let LayerInfoPlacement::GlobalBlock { index, key: written, .. } = saved.layer_info_placement else { panic!("{depth:?}: a global layer block") }; + assert_eq!(written, key); + let at = saved.global_blocks.iter().position(|b| b.key == marker).expect("the marker is kept"); + assert!(at < index, "{depth:?}: {marker:?} at {at}, the layer block at {index}"); + } + // Without a marker the layer block stays first. + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 211bba4da9..94c4ea9932 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -595,14 +595,83 @@ fn unicode_names(data: &[u8]) -> Vec { while at + 4 <= data.len() { let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize; at += 4; - let Some(b) = data.get(at..at + n * 2) else { break }; + // Checked: `n * 2` overflows a 32-bit `usize` for a hostile length. + let Some(end) = n.checked_mul(2).and_then(|len| at.checked_add(len)) else { break }; + let Some(b) = data.get(at..end) else { break }; let units: Vec = b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); - at += n * 2; + at = end; } out } +/// Plane `i` of a decoded merged image whose planes are `plane` bytes each, or `None` when it is +/// short or the offsets overflow. +fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> { + all.get(i.checked_mul(plane)?..i.checked_add(1)?.checked_mul(plane)?) +} + +/// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit +/// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as +/// RGB. An output over `cap` bytes is refused, never allocated (#5200). +fn flat_composite( + file: &PsdFile, + merged: Option<&Result, photocraft_psd::PsdError>>, + cap: u64, +) -> Result { + use photocraft_psd::PsdError; + match file.composite_rgba8() { + Ok(img) => return Ok(img), + // Over the output budget: the fallback below would make the same allocation. + Err(e @ PsdError::LimitExceeded(_)) => return Err(e), + Err(_) => {} + } + let all = match merged { + Some(Ok(all)) => all, + Some(Err(e)) => return Err(e.clone()), + None => return Err(PsdError::Invalid("no merged image".into())), + }; + let h = &file.header; + let (w, hh) = (h.width as usize, h.height as usize); + let len = w + .checked_mul(hh) + .and_then(|n| n.checked_mul(4)) + .filter(|&len| len as u64 <= cap) + .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; + let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; + let mut data = Vec::new(); + data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + data.resize(len, 255u8); + for c in 0..3.min(usize::from(h.channels)) { + let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; + let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; + for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) { + px[c] = *v; + } + } + Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) +} + +/// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size +/// float copy of the image). +fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { + let fmt = s.format(); + let row = (canvas.width() as usize).saturating_mul(4); + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + let start = ((y - canvas.y0) as usize).saturating_mul(row); + let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break }; + vals.clear(); + for p in rows.as_chunks::<4>().0 { + vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0))); + } + s.write_region(band, &vals); + y = band.y1; + } +} + fn parse_guides(data: &[u8], doc: &mut Document) { let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let Some(count) = rd(12) else { return }; @@ -623,10 +692,21 @@ fn parse_guides(data: &[u8], doc: &mut Document) { /// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is /// written back as one. fn flat_background(s: Surface) -> Layer { + // A band of tile rows at a time (no full-size float copy), stopping at the first + // transparent pixel. let opaque = { let r = s.content_bounds(); let n = s.channels(); - !r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)) + let mut vals = Vec::new(); + let mut y = r.y0; + let mut opaque = !r.is_empty(); + while opaque && y < r.y1 { + let band = Rect::new(r.x0, y, r.x1, y.saturating_add(TILE_SIZE).min(r.y1)); + s.read_region_into(band, &mut vals); + opaque = vals.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + y = band.y1; + } + opaque }; let mut l = Layer::new("Background", LayerContent::Raster(s)); l.locks.transparency = opaque; @@ -771,7 +851,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } let (w, hh) = (h.width as usize, h.height as usize); - let n = w * hh; + // Pixels per plane and bytes per merged plane, checked: a 1-bit plane can decode within the + // budget while `w * hh` does not fit a 32-bit `usize` (wasm32). + let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh)); let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); // The merged composite is decoded only when something consumes it: a flattened or // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour @@ -821,12 +903,13 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } if doc.layers.is_empty() && let Some(Ok(all)) = &merged + // Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes. + && let Some((n, plane)) = sizes { // Flattened file: the merged image becomes the background layer. - let plane = h.row_bytes() * hh; let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; - let mut planes: Vec> = (0..cc).map(|c| all.get(c * plane..(c + 1) * plane)).collect(); - planes.push(alpha_idx.and_then(|a| all.get(a * plane..(a + 1) * plane))); + let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); + planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); let mut fill = vec![zero_sample(depth); cc]; fill.push(max_sample(depth)); let mut inv = vec![cx.cmyk; cc]; @@ -882,47 +965,32 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } - let rgba = file.composite_rgba8().or_else(|_| { - // Multichannel: show the first channels as RGB. - let all = merged.clone().ok_or_else(|| photocraft_psd::PsdError::Invalid("no merged image".into()))??; - let plane = h.row_bytes() * hh; - let mut data = vec![255u8; n * 4]; - for c in 0..3.min(usize::from(h.channels)) { - let p = photocraft_psd::pixels::plane_to_u8(&all[c * plane..(c + 1) * plane], h.depth, w, hh)?; - for i in 0..n { - data[i * 4 + c] = p[i]; - } + match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + Ok(img) => { + let mut s = Surface::new(fmt); + write_rgba8(&mut s, canvas, &img.data); + drop(img); + s.prune(); + doc.layers.push(flat_background(s)); } - Ok::<_, photocraft_psd::PsdError>(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) - }); - if let Ok(img) = rgba { - let mut s = Surface::new(fmt); - let vals: Vec = img - .data - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| { - let v = photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], p[3]].map(|x| f32::from(x) / 255.0)); - v.into_iter() - }) - .collect(); - s.write_region(canvas, &vals); - s.prune(); - doc.layers.push(flat_background(s)); + // A merged image that failed to decode was reported above. + Err(_) if matches!(merged, Some(Err(_))) => {} + Err(e) => cx.warn(format!("the image could not be read: {e}")), } } // Extra (alpha / spot) channels of the merged image. - if layered && let Some(Ok(all)) = &merged { + if layered + && let Some(Ok(all)) = &merged + && let Some((n, plane)) = sizes + { let first = cc + usize::from(file.merged_has_alpha()); - let plane = h.row_bytes() * hh; let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); for (k, idx) in (first..usize::from(h.channels)).enumerate() { - let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; + let Some(p) = merged_plane(all, idx, plane) else { break }; let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); s.prune(); @@ -962,3 +1030,54 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) ctl.progress(1.0); Some((doc, cx.warnings)) } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_psd::{Compression, PsdError, Version, testgen}; + + /// #5200: the Multichannel fallback checks its RGBA output against the budget before + /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). + #[test] + fn flat_fallback_refuses_an_output_over_the_cap() { + let file = testgen::merged_only(Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 2, 2); + assert!(file.composite_rgba8().is_err(), "Multichannel goes through the fallback"); + let merged = file.decode_merged(); + assert!(matches!(flat_composite(&file, Some(&merged), 15), Err(PsdError::LimitExceeded(_)))); + let img = flat_composite(&file, Some(&merged), 16).unwrap(); + assert_eq!(img.data.len(), 16); + } + + /// #5200: a composite over the budget is not retried by the fallback (which allocated the + /// refused buffer: 14.4 GB for this 60000x60000 Bitmap header). + #[test] + fn flat_fallback_does_not_retry_an_over_budget_composite() { + let mut file = testgen::small(Version::Psb, Compression::Raw); + file.header.color_mode = PsdMode::Bitmap; + file.header.depth = 1; + file.header.channels = 1; + file.header.width = 60_000; + file.header.height = 60_000; + let merged = Ok(vec![0u8; 16]); + let cap = photocraft_psd::compression::MAX_DECODED_BYTES; + assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_)))); + } + + /// #5202: a hostile name length ends the list instead of overflowing. + #[test] + fn unicode_names_stop_at_a_hostile_length() { + let mut data = 1u32.to_be_bytes().to_vec(); + data.extend([0, b'A']); + data.extend(u32::MAX.to_be_bytes()); + data.extend([0, b'B']); + assert_eq!(unicode_names(&data), ["A"]); + } + + #[test] + fn merged_plane_checks_offsets() { + let all = [1u8, 2, 3, 4]; + assert_eq!(merged_plane(&all, 1, 2), Some(&[3u8, 4][..])); + assert_eq!(merged_plane(&all, 2, 2), None); + assert_eq!(merged_plane(&all, usize::MAX, 2), None); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index ab9349fbde..6eeda61a13 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -289,6 +289,153 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) Some((k.command, params)) } +// ---------- Filter Gallery ---------- +// +// Filter › Filter Gallery stores its effect stack in a `GEfc` filter (filterID `GEfc`). Photoshop +// writes a single effect inline; Photopea writes a list (also for one effect): +// +// ```text +// Fltr = GEfc { GEfk = GEft enum (the effect), } (Photoshop) +// Fltr = GEfc { GEfs = [ GEfc { , GEfk = GEft enum, GELv = visible } ] } (Photopea) +// ``` +// +// The list is in application order, like `filter.filterGallery`'s `effects`. The item's +// `FrgC`/`BckC` are the colours the Sketch effects read. Only effects whose settings we know are +// mapped; a stack with any other effect (or setting) stays an [`UNSUPPORTED_FILTER`]. + +const GALLERY_CLASS: &str = "GEfc"; +const GALLERY_COMMAND: &str = "filter.filterGallery"; +const GALLERY_NAME: &str = "Filter Gallery..."; + +/// One gallery effect: our filter key, Photoshop's `GEft` code and its settings (PSD key, our key). +struct GalleryCode { + key: &'static str, + code: &'static str, + settings: &'static [(&'static str, &'static str)], +} + +const GALLERY_EFFECTS: [GalleryCode; 1] = [GalleryCode { key: "chrome", code: "Chrm", settings: &[("Dtl ", "detail"), ("Smth", "smoothness")] }]; + +/// An `RGBC` descriptor (0–255 channels) as straight `[r, g, b, a]`, 0–1. +fn rgbc_colour(d: &Descriptor) -> Option { + let c = |k: &str| px(d, k).map(|v| (v / 255.0).clamp(0.0, 1.0)); + Some(json!([c("Rd ")?, c("Grn ")?, c("Bl ")?, 1.0])) +} + +/// `filter.filterGallery` params for a `GEfc` filter item; `None` when an effect or setting is +/// not modelled. +fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { + let list: Vec<&Descriptor> = match f.get("GEfs") { + Some(Value::List(list)) => list + .iter() + .map(|v| match v { + Value::Descriptor(e) => Some(e), + _ => None, + }) + .collect::>()?, + Some(_) => return None, + None if f.get("GEfk").is_some() => vec![f], + None => return None, + }; + let mut effects = Vec::with_capacity(list.len()); + for e in list { + let code = enum_of(e, "GEfk")?; + let g = GALLERY_EFFECTS.iter().find(|g| g.code.as_bytes() == code)?; + let mut params = Map::new(); + for (k, v) in &e.items { + if k.is("GEfk") || k.is("GELv") { + continue; + } + let (_, ours) = g.settings.iter().find(|(psd, _)| k.is(psd))?; + params.insert((*ours).into(), json!(int_value(v)?)); + } + effects.push(json!({"filter": g.key, "params": params, "visible": bool_or(e, "GELv", true)})); + } + if effects.is_empty() { + return None; + } + let mut out = Map::new(); + out.insert("effects".into(), J::Array(effects)); + for (psd, ours) in [("FrgC", "foreground"), ("BckC", "background")] { + if let Some(c) = get_desc(item, psd).and_then(rgbc_colour) { + out.insert(ours.into(), c); + } + } + Some(J::Object(out)) +} + +fn colour_rgb(v: &J) -> Option<[f64; 3]> { + match v { + J::Array(a) => Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]), + J::String(h) if h.len() == 7 && h.starts_with('#') => { + let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); + Some([ch(1)?, ch(3)?, ch(5)?]) + } + _ => None, + } +} + +/// A `[r, g, b(, a)]` (0–1) or `#rrggbb` colour param as an `RGBC` descriptor (0–255). +fn colour_rgbc(v: Option<&J>, default: f64) -> Result { + let Some(v) = v else { return Ok(rgbc(default)) }; + let rgb = colour_rgb(v).filter(|c| c.iter().all(|v| v.is_finite())).ok_or("a colour is not [r, g, b] or #rrggbb")?; + let c = |v: f64| Value::Double(v.clamp(0.0, 1.0) * 255.0); + Ok(Value::Descriptor(Descriptor::new("RGBC").with("Rd ", c(rgb[0])).with("Grn ", c(rgb[1])).with("Bl ", c(rgb[2])))) +} + +/// The `filterFXList` item for a `filter.filterGallery` smart filter. +fn gallery_item(f: &SmartFilter) -> Result { + let p = &f.params; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !is_meta(k) && !["effects", "foreground", "background"].contains(&k.as_str()))) { + return Err(format!("setting `{k}` has no PSD equivalent")); + } + let list = p.get("effects").and_then(J::as_array).ok_or("it has no effects")?; + let mut out = Vec::with_capacity(list.len()); + for e in list { + let key = e.get("filter").and_then(J::as_str).ok_or("an effect has no `filter`")?; + let g = GALLERY_EFFECTS.iter().find(|g| g.key == key).ok_or_else(|| format!("gallery effect `{key}` has no PSD equivalent"))?; + let params = e.get("params").filter(|v| v.is_object()).unwrap_or(e); + if let Some(k) = params.as_object().and_then(|o| { + o.keys().find(|k| !is_meta(k) && !["filter", "params", "visible"].contains(&k.as_str()) && !g.settings.iter().any(|(_, ours)| ours == k)) + }) { + return Err(format!("gallery setting `{key}.{k}` has no PSD equivalent")); + } + let defaults = photocraft_algo::GalleryFilter::from_key(key).map(photocraft_algo::GalleryEffect::new); + let settings: Vec<(&str, Value)> = + g.settings.iter().map(|(psd, ours)| (*psd, pi(params, ours, defaults.as_ref().map_or(0.0, |x| f64::from(x.get(ours)))))).collect(); + out.push((g.code, settings, e.get("visible").and_then(J::as_bool).unwrap_or(true))); + } + let gallery = || { + let mut d = Descriptor::new(GALLERY_CLASS); + d.name = UnicodeString::new_nul("Filter Gallery"); + d + }; + // One visible effect: inline, as Photoshop writes it. Otherwise the effect list. + let fltr = match out.as_slice() { + [] => return Err("it has no effects".into()), + [(code, settings, true)] => settings.iter().fold(gallery().with("GEfk", enumv("GEft", code)), |d, (k, v)| d.with(k, v.clone())), + _ => { + let list = out + .into_iter() + .map(|(code, settings, visible)| { + let d = settings.into_iter().fold(gallery(), |d, (k, v)| d.with(k, v)); + Value::Descriptor(d.with("GEfk", enumv("GEft", code)).with("GELv", Value::Boolean(visible))) + }) + .collect(); + gallery().with("GEfs", Value::List(list)) + } + }; + Ok(Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(GALLERY_NAME))) + .with("blendOptions", blend_options(f)) + .with("enab", Value::Boolean(f.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", colour_rgbc(p.get("foreground"), 0.0)?) + .with("BckC", colour_rgbc(p.get("background"), 255.0)?) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(b"GEfc")))) +} + /// Lowercase hex of `bytes`. fn to_hex(bytes: &[u8]) -> String { const H: &[u8; 16] = b"0123456789abcdef"; @@ -322,6 +469,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), c => c.and_then(|c| known_params(c, fltr)), }; let (command, params) = match known { @@ -473,6 +621,9 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { set(&mut d, "enab", Value::Boolean(f.visible)); return Ok(d); } + if f.command == GALLERY_COMMAND { + return gallery_item(f); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; @@ -913,6 +1064,161 @@ mod tests { assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); } + /// A Filter Gallery item as Photopea writes it (Chrome, Detail 12, Smoothness 4). + fn gallery_item_with(effects: Vec) -> Descriptor { + let rgb = + |v: f64| Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(v)).with("Grn ", Value::Double(v)).with("Bl ", Value::Double(v))); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filtergalerie"))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgb(0.0)) + .with("BckC", rgb(255.0)) + .with("Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfs", Value::List(effects.into_iter().map(Value::Descriptor).collect())))) + .with("filterID", Value::Integer(code(b"GEfc"))) + } + + fn chrome(detail: i32, smoothness: i32, visible: bool) -> Descriptor { + Descriptor::new("GEfc") + .with("Dtl ", Value::Integer(detail)) + .with("Smth", Value::Integer(smoothness)) + .with("GEfk", enumv("GEft", "Chrm")) + .with("GELv", Value::Boolean(visible)) + } + + #[test] + fn filter_gallery_chrome_imports_as_editable_gallery() { + let f = filter_from_item(&gallery_item_with(vec![chrome(12, 4, true), chrome(3, 9, false)])); + assert_eq!(f.command, "filter.filterGallery"); + assert_eq!( + f.params, + json!({ + "effects": [ + {"filter": "chrome", "params": {"detail": 12, "smoothness": 4}, "visible": true}, + {"filter": "chrome", "params": {"detail": 3, "smoothness": 9}, "visible": false}, + ], + "foreground": [0.0, 0.0, 0.0, 1.0], + "background": [1.0, 1.0, 1.0, 1.0], + }) + ); + // Written back and read again: unchanged, also when re-blended and hidden. + let mut g = f.clone(); + g.blend = BlendMode::Multiply; + g.opacity = 0.5; + g.visible = false; + let d = item_for_filter(&g).unwrap(); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), g); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("GEfc")); + assert_eq!(int(&d, "filterID"), Some(code(b"GEfc"))); + } + + /// A single effect as Photoshop 27 writes it: inline in `Fltr`, no `GEfs` list or `GELv`. + fn photoshop_chrome(detail: i32, smoothness: i32) -> Descriptor { + let rgb = + |v: f64| Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(v)).with("Grn ", Value::Double(v)).with("Bl ", Value::Double(v))); + let mut fltr = + Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Chrm")).with("Dtl ", Value::Integer(detail)).with("Smth", Value::Integer(smoothness)); + fltr.name = UnicodeString::new_nul("Filter Gallery"); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter Gallery..."))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "Nrml")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgb(255.0)) + .with("BckC", rgb(0.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(b"GEfc"))) + } + + #[test] + fn filter_gallery_photoshop_inline_effect_imports_and_writes_back_inline() { + let item = photoshop_chrome(10, 4); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.filterGallery"); + assert_eq!( + f.params, + json!({ + "effects": [{"filter": "chrome", "params": {"detail": 10, "smoothness": 4}, "visible": true}], + "foreground": [1.0, 1.0, 1.0, 1.0], + "background": [0.0, 0.0, 0.0, 1.0], + }) + ); + // Written back in Photoshop's own layout. + let d = item_for_filter(&f).unwrap(); + assert_eq!(get_desc(&d, "Fltr"), get_desc(&item, "Fltr")); + assert_eq!((get_desc(&d, "FrgC"), get_desc(&d, "BckC")), (get_desc(&item, "FrgC"), get_desc(&item, "BckC"))); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + // A hidden single effect needs the list (only it has a visibility flag). + let mut hidden = f.clone(); + hidden.params["effects"][0]["visible"] = json!(false); + let d = item_for_filter(&hidden).unwrap(); + assert!(get_desc(&d, "Fltr").unwrap().get("GEfs").is_some()); + assert_eq!(filter_from_item(&d), hidden); + // Inline effects we don't model stay verbatim. + let mut odd = photoshop_chrome(10, 4); + set(&mut odd, "Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Ctot")).with("Dtl ", Value::Integer(1)))); + assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); + let mut extra = photoshop_chrome(10, 4); + set(&mut extra, "Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Chrm")).with("Xtra", Value::Integer(1)))); + assert_eq!(filter_from_item(&extra).command, UNSUPPORTED_FILTER); + } + + #[test] + fn gallery_table_matches_the_gallery_filters() { + for g in &GALLERY_EFFECTS { + let f = photocraft_algo::GalleryFilter::from_key(g.key).unwrap(); + let keys: Vec<&str> = f.params().iter().map(|p| p.key).collect(); + for (_, ours) in g.settings { + assert!(keys.contains(ours), "{}.{ours}", g.key); + } + } + } + + #[test] + fn filter_gallery_written_with_defaults_and_colours() { + let f = sf("filter.filterGallery", json!({"effects": [{"filter": "chrome"}], "foreground": "#ff0000", "background": [0.0, 0.0, 1.0, 1.0]})); + let back = filter_from_item(&item_for_filter(&f).unwrap()); + assert_eq!(back.params["effects"][0]["params"], json!({"detail": 4, "smoothness": 7})); + assert_eq!(back.params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(back.params["background"], json!([0.0, 0.0, 1.0, 1.0])); + } + + #[test] + fn filter_gallery_we_dont_model_is_kept_verbatim() { + // Another effect, an extra setting, a wrong-typed setting, no effects, a non-list. + let other = Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Ctot")).with("GELv", Value::Boolean(true)); + let extra = chrome(4, 7, true).with("Xtra", Value::Integer(1)); + let typed = Descriptor::new("GEfc").with("Dtl ", Value::Double(4.0)).with("GEfk", enumv("GEft", "Chrm")); + for effects in [vec![chrome(4, 7, true), other], vec![extra], vec![typed], vec![]] { + let item = gallery_item_with(effects); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + let odd = Descriptor::new("filterFX").with("Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfs", Value::Integer(3)))); + assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"GEfc"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn filter_gallery_without_psd_equivalent_is_reported() { + for p in [ + json!({"effects": [{"filter": "cutout"}]}), + json!({"effects": [{"filter": "chrome", "params": {"sparkle": 1}}]}), + json!({"effects": [{"params": {}}]}), + json!({"effects": []}), + json!({}), + json!({"effects": [{"filter": "chrome"}], "list": true}), + json!({"effects": [{"filter": "chrome"}], "foreground": "red"}), + json!(null), + ] { + assert!(item_for_filter(&sf("filter.filterGallery", p.clone())).is_err(), "{p}"); + } + } + #[test] fn unwritable_filters_are_reported_not_fatal() { let stack = FilterStack { diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 776f27f89d..a8d81a1600 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -148,15 +148,15 @@ struct Source { } /// Hand-picked mix in `corpus/psd` (170 files: 134 vs the merged image + 12 vs the thumbnail). -const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 146, roundtrip_floor: 169 }; +const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 148, roundtrip_floor: 169 }; /// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`): /// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7). -const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 308 }; +const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 249, roundtrip_floor: 308 }; /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 145, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index a20b841c17..a63fc559e7 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -248,7 +248,7 @@ impl History { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; tiles::surfaces(doc, |surface| { @@ -408,6 +408,42 @@ mod tests { assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4); } + #[test] + fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() { + use photocraft_doc::{Affine, Fill, FillCache, LayerContent, LayerMask, PixelFormat, SmartObject, SmartSource, Surface}; + let mut doc = Document::new("fills", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut fill = Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::WHITE))); + let mut cache = Surface::new(PixelFormat::GRAY8); + cache.write_pixel(1, 1, &[1.0]); + fill.fill_cache = Some(FillCache { fill: Fill::Solid(Color::WHITE), surface: cache }); + doc.insert_above(None, fill); + let mut smart_obj = SmartObject::new(SmartSource::Linked { path: "a.png".into() }, Affine::IDENTITY, None); + let mut mask = LayerMask::reveal_all(); + mask.surface.write_pixel(1, 1, &[0.5]); + smart_obj.filter_mask = Some(mask); + doc.insert_above(None, Layer::new("Smart", LayerContent::Smart(smart_obj))); + let tile = 256 * 256; + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "fill cache and smart filter mask are counted"); + edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into()); + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "shared tiles count once"); + edit(&mut h, &mut cur, "Fill cache", |doc| { + let id = doc.layers[0].id; + if let Some(cache) = doc.layer_mut(id).and_then(|l| l.fill_cache.as_mut()) { + cache.surface.write_pixel(1, 1, &[0.0]); + } + }); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the copied fill cache tile is history-only"); + assert_eq!(h.unique_bytes(&cur), tile); + h.trim(&cur); + assert_counter(&h, &cur); + h.max_bytes = 2 * tile; + assert_eq!(h.trim(&cur), 1, "the copied fill cache tile pushes history over budget"); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the most recent undo state is always kept"); + assert_counter(&h, &cur); + } + #[test] fn selection_tiles_count_current_and_history_without_double_counting() { use photocraft_doc::{PixelFormat, Surface}; diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index b8d27775f9..ec191f301a 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -1,7 +1,7 @@ //! Tile references owned by history snapshots. The snapshots keep these addresses alive; //! COW edits cannot change a retained tile in place. Count occurrences (including aliases //! within one document), so removing a state releases a tile only after its last reference. -use photocraft_doc::{Document, Surface}; +use photocraft_doc::{Document, LayerContent, Surface}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -19,6 +19,14 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { if let Some(mask) = &layer.mask { visit(&mask.surface); } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } if let Some(video) = &layer.video { // Undo retains the whole clip, not only the displayed frame. Visit aliases too: // Tiles tracks every owner and counts their shared allocations only once. diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 0dd4d21d65..d60412a58d 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -10,6 +10,7 @@ use photocraft_color::BlendMode; use serde::{Deserialize, Serialize}; +use crate::curve::ResponseCurve; use crate::mixer::MixerSettings; use crate::tile::{GrayTile, StoredTile}; @@ -51,20 +52,24 @@ pub struct Dynamic { pub fade_steps: u32, /// 0..1 floor the control/jitter may reduce the value to (minimum diameter, roundness, depth…). pub minimum: f32, + /// This dynamic's own response to its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, + /// Rotation), applied after the global pen curve. Linear by default. Always written to JSON, so + /// a `tools.setBrush` patch can set it back to linear. Angle Jitter's controls ignore it. + pub curve: ResponseCurve, } impl Default for Dynamic { fn default() -> Self { - Self { jitter: 0.0, control: Control::Off, fade_steps: 25, minimum: 0.0 } + Self { jitter: 0.0, control: Control::Off, fade_steps: 25, minimum: 0.0, curve: ResponseCurve::LINEAR } } } impl Dynamic { pub const fn controlled(control: Control) -> Self { - Self { jitter: 0.0, control, fade_steps: 25, minimum: 0.0 } + Self { jitter: 0.0, control, fade_steps: 25, minimum: 0.0, curve: ResponseCurve::LINEAR } } pub const fn jitter(jitter: f32) -> Self { - Self { jitter, control: Control::Off, fade_steps: 25, minimum: 0.0 } + Self { jitter, control: Control::Off, fade_steps: 25, minimum: 0.0, curve: ResponseCurve::LINEAR } } pub fn is_active(&self) -> bool { self.jitter > 0.0 || self.control != Control::Off @@ -582,6 +587,15 @@ impl Default for BrushSettings { } impl BrushSettings { + /// Resize the primary tip and keep an enabled Dual Brush tip at the same relative size. + /// An explicit Dual Brush size edit should bypass this and retain the requested diameter. + pub fn resize_with_dual_brush(&mut self, new_size: f32) { + if self.dual_brush.enabled && self.size.is_finite() && self.size > 0.0 && new_size.is_finite() && new_size > 0.0 { + self.dual_brush.size = (self.dual_brush.size * (new_size / self.size)).clamp(0.5, MAX_BRUSH_SIZE); + } + self.size = new_size; + } + /// Copy the settings with the primary and dual diameters and Scatter amounts constrained for /// rasterization. /// @@ -714,7 +728,7 @@ impl BrushPreset { pub fn picked_over(&self, current: &BrushSettings) -> BrushSettings { let mut b = self.brush.clone().picked_over(current); if !self.captures.size { - b.size = current.size; + b.resize_with_dual_brush(current.size); } if !self.captures.tool_settings { b.mode = current.mode; diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 284b7ae3b9..d0d7de4110 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -263,6 +263,28 @@ fn pressure_controls_size_with_minimum() { assert!(d.windows(2).all(|w| w[1].radius >= w[0].radius - 1e-4)); } +#[test] +fn each_dynamic_responds_through_its_own_curve() { + // A hard size curve (half pressure gives a fifth of the size range) and a linear opacity. + let curve = crate::ResponseCurve::new(&[[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + let b = BrushSettings { + size: 40.0, + shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic { curve, ..Dynamic::controlled(Control::PenPressure) }, ..Default::default() }, + transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() }, + ..brush() + }; + let d = dabs_of(&b, &[StrokePoint::new(0.0, 0.0, 0.5), StrokePoint::new(1.0, 0.0, 0.5)]); + assert!((d[0].radius - 4.0).abs() < 1e-3, "20 % of a 40 px diameter → r 4, got {}", d[0].radius); + assert!((d[0].opacity - 0.5).abs() < 1e-3, "opacity keeps its linear response: {}", d[0].opacity); + // The curve is part of the brush's JSON. + let json = serde_json::to_value(&b).unwrap(); + let stored: Vec<[f32; 2]> = serde_json::from_value(json["shapeDynamics"]["size"]["curve"].clone()).unwrap(); + assert_eq!(stored, vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + assert_eq!(json["transfer"]["opacity"]["curve"], serde_json::json!([[0.0, 0.0], [1.0, 1.0]])); + let back: BrushSettings = serde_json::from_value(json).unwrap(); + assert_eq!(back.shape_dynamics.size.curve, curve); +} + #[test] fn fade_ramps_over_steps() { let b = BrushSettings { diff --git a/crates/paint/src/curve.rs b/crates/paint/src/curve.rs new file mode 100644 index 0000000000..4ee766ae95 --- /dev/null +++ b/crates/paint/src/curve.rs @@ -0,0 +1,173 @@ +//! A response curve: how a 0..1 input (pen pressure, tilt, wheel, a fade ramp) maps to a 0..1 +//! output. Preferences › Tools › Pressure Curve shapes the pen's pressure globally; every brush +//! dynamic carries its own curve for its control ([`crate::Dynamic::curve`]). + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Most control points a curve keeps. +const MAX: usize = 16; + +/// A monotone cubic (Fritsch–Carlson) through `[input, output]` control points, so a stronger +/// input never gives less output. Points are sanitised, not trusted: non-finite points are dropped, +/// the rest clamped to 0..1, sorted by input (one point per input, the later output wins), capped +/// at [`Self::MAX_POINTS`], and their outputs made non-decreasing. Before the first point and after +/// the last the curve stays flat. Fewer than two usable points is linear. +/// +/// Fixed-size and `Copy`, so a brush evaluates it per dab without allocating. In JSON it is its +/// list of points, `[[0,0],[0.5,0.2],[1,1]]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResponseCurve { + n: u8, + xs: [f32; MAX], + ys: [f32; MAX], + /// Tangents at the points. + ms: [f32; MAX], +} + +const fn padded(a: f32, b: f32) -> [f32; MAX] { + let mut v = [0.0; MAX]; + v[0] = a; + v[1] = b; + v +} + +impl Default for ResponseCurve { + fn default() -> Self { + Self::LINEAR + } +} + +impl ResponseCurve { + pub const MAX_POINTS: usize = MAX; + + /// The identity: input passes through unchanged. + pub const LINEAR: Self = Self { n: 2, xs: padded(0.0, 1.0), ys: padded(0.0, 1.0), ms: padded(1.0, 1.0) }; + + pub fn new(points: &[[f32; 2]]) -> Self { + let mut pts: Vec<[f32; 2]> = + points.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect(); + pts.sort_by(|a, b| a[0].total_cmp(&b[0])); + pts.dedup_by(|b, a| { + // Same input: keep the later point's output. + let same = (a[0] - b[0]).abs() < 1e-6; + if same { + a[1] = b[1]; + } + same + }); + pts.truncate(MAX); + let n = pts.len(); + if n < 2 { + return Self::LINEAR; + } + let mut c = Self { n: n as u8, xs: [0.0; MAX], ys: [0.0; MAX], ms: [0.0; MAX] }; + let mut prev = 0.0f32; + for (k, p) in pts.iter().enumerate() { + c.xs[k] = p[0]; + // Outputs never fall: the curve is monotone. + prev = if k == 0 { p[1] } else { p[1].max(prev) }; + c.ys[k] = prev; + } + // Fritsch–Carlson tangents: secant averages, zero at flat steps, limited so the cubic + // never overshoots (which keeps it monotone). + let mut d = [0.0f32; MAX]; + for (k, dk) in d.iter_mut().enumerate().take(n - 1) { + *dk = (c.ys[k + 1] - c.ys[k]) / (c.xs[k + 1] - c.xs[k]); + } + c.ms[0] = d[0]; + c.ms[n - 1] = d[n - 2]; + for k in 1..n - 1 { + c.ms[k] = if d[k - 1] * d[k] <= 0.0 { 0.0 } else { (d[k - 1] + d[k]) / 2.0 }; + } + for (k, &dk) in d.iter().enumerate().take(n - 1) { + if dk == 0.0 { + c.ms[k] = 0.0; + c.ms[k + 1] = 0.0; + continue; + } + let (a, b) = (c.ms[k] / dk, c.ms[k + 1] / dk); + let s = a * a + b * b; + if s > 9.0 { + let t = 3.0 / s.sqrt(); + c.ms[k] = t * a * dk; + c.ms[k + 1] = t * b * dk; + } + } + c + } + + /// The control points (sanitised). + pub fn points(&self) -> Vec<[f32; 2]> { + self.xs.iter().zip(&self.ys).take(usize::from(self.n)).map(|(&x, &y)| [x, y]).collect() + } + + /// The curve's output for input `x` (0..1; anything else is clamped, NaN reads as 0). + pub fn eval(&self, x: f32) -> f32 { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + if self.is_linear() { + return x; + } + let n = usize::from(self.n).min(MAX); + let (xs, ys, ms) = (self.xs.get(..n).unwrap_or(&[]), self.ys.get(..n).unwrap_or(&[]), self.ms.get(..n).unwrap_or(&[])); + let (Some(&x0), Some(&xn)) = (xs.first(), xs.last()) else { return x }; + if x <= x0 { + return ys.first().copied().unwrap_or(x); + } + if x >= xn { + return ys.last().copied().unwrap_or(x); + } + let k = xs.partition_point(|&v| v <= x).saturating_sub(1).min(n.saturating_sub(2)); + let (Some(&xa), Some(&xb), Some(&ya), Some(&yb), Some(&ma), Some(&mb)) = (xs.get(k), xs.get(k + 1), ys.get(k), ys.get(k + 1), ms.get(k), ms.get(k + 1)) + else { + return x; + }; + let h = xb - xa; + let t = (x - xa) / h; + let (t2, t3) = (t * t, t * t * t); + let y = (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * ma + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * mb; + y.clamp(ya.min(yb), ya.max(yb)) + } + + /// The identity (the default curve). + pub fn is_linear(&self) -> bool { + self.n == 2 && self.xs[..2] == [0.0, 1.0] && self.ys[..2] == [0.0, 1.0] + } +} + +impl Serialize for ResponseCurve { + fn serialize(&self, s: S) -> Result { + self.points().serialize(s) + } +} + +impl<'de> Deserialize<'de> for ResponseCurve { + fn deserialize>(d: D) -> Result { + Ok(Self::new(&Vec::<[f32; 2]>::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_curve_is_monotone_sanitised_and_round_trips_as_points() { + assert!(ResponseCurve::new(&[[0.0, 0.0], [1.0, 1.0]]).is_linear()); + assert_eq!(ResponseCurve::new(&[[0.0, 0.0], [1.0, 1.0]]), ResponseCurve::LINEAR); + assert!(ResponseCurve::new(&[[f32::NAN, 0.3]]).is_linear(), "fewer than two usable points is linear"); + let c = ResponseCurve::new(&[[1.0, 1.0], [0.5, 0.2], [0.0, 0.0]]); + assert!((c.eval(0.5) - 0.2).abs() < 1e-6); + let mut prev = 0.0; + for i in 0..=100 { + let y = c.eval(i as f32 / 100.0); + assert!(y >= prev - 1e-6 && (0.0..=1.0).contains(&y)); + prev = y; + } + let json = serde_json::to_string(&c).unwrap_or_default(); + assert_eq!(json, "[[0.0,0.0],[0.5,0.2],[1.0,1.0]]"); + let back: ResponseCurve = serde_json::from_str(&json).unwrap_or_default(); + assert_eq!(back, c); + let many: Vec<[f32; 2]> = (0..40).map(|i| [i as f32 / 40.0, 0.5]).collect(); + assert_eq!(ResponseCurve::new(&many).points().len(), ResponseCurve::MAX_POINTS); + } +} diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 25354dd6d5..ab846f1959 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -356,6 +356,16 @@ pub fn control_value(c: Control, fade_steps: u32, s: &StepInput) -> f32 { } } +/// A dynamic's control value passed through its own response curve ([`Dynamic::curve`]). Controls +/// with no 0..1 input (Off, the direction controls) read 1 and skip the curve. +pub fn dynamic_value(d: &Dynamic, s: &StepInput) -> f32 { + let v = control_value(d.control, d.fade_steps, s); + match d.control { + Control::Off | Control::InitialDirection | Control::Direction => v, + Control::Fade | Control::PenPressure | Control::PenTilt | Control::StylusWheel | Control::Rotation => d.curve.eval(v), + } +} + /// Angle contributed by a control, in degrees (counter-clockwise). pub fn control_angle(c: Control, fade_steps: u32, s: &StepInput) -> f32 { let p = &s.point; @@ -384,7 +394,7 @@ fn scalar(d: &Dynamic, s: &StepInput, r: f32) -> f32 { return 1.0; } let min = d.minimum.clamp(0.0, 1.0); - let cv = control_value(d.control, d.fade_steps, s); + let cv = dynamic_value(d, s); let v = (min + (1.0 - min) * cv) * (1.0 - d.jitter.clamp(0.0, 1.0) * r); v.max(min) } @@ -425,7 +435,7 @@ pub fn dynamic_color(b: &BrushSettings, s: &StepInput, seed: u64, index: u64) -> let cd = &b.color_dynamics; let (fg, bg) = (b.color, b.background); let r = |st: u64| rand01(seed, index, st); - let base = if cd.fg_bg.control != Control::Off { 1.0 - control_value(cd.fg_bg.control, cd.fg_bg.fade_steps, s) } else { 0.0 }; + let base = if cd.fg_bg.control != Control::Off { 1.0 - dynamic_value(&cd.fg_bg, s) } else { 0.0 }; let t = (base + (1.0 - base) * cd.fg_bg.jitter.clamp(0.0, 1.0) * r(stream::FG_BG)).clamp(0.0, 1.0); let rgb = [fg[0] + (bg[0] - fg[0]) * t, fg[1] + (bg[1] - fg[1]) * t, fg[2] + (bg[2] - fg[2]) * t]; let [mut h, mut sat, mut v] = rgb_to_hsv(rgb); @@ -444,7 +454,7 @@ pub fn base_diameter(b: &BrushSettings, s: &StepInput) -> f32 { let mut f = 1.0; if sd.enabled && sd.size.control != Control::Off { let min = sd.size.minimum.clamp(0.0, 1.0); - f = min + (1.0 - min) * control_value(sd.size.control, sd.size.fade_steps, s); + f = min + (1.0 - min) * dynamic_value(&sd.size, s); } else if b.pressure_size { f = s.point.pressure.clamp(0.0, 1.0); } @@ -530,7 +540,7 @@ impl DabBuilder { // Scatter. let mut c = Point::new(s.point.x, s.point.y); if sc.enabled && sc.scatter.jitter > 0.0 { - let cv = control_value(sc.scatter.control, sc.scatter.fade_steps, s); + let cv = dynamic_value(&sc.scatter, s); let amt = f64::from(sc.scatter.jitter * cv * radius); let (ox, oy) = (rand_signed(seed, i, stream::SCATTER_X) as f64 * amt, rand_signed(seed, i, stream::SCATTER_Y) as f64 * amt); if sc.both_axes { diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 4dc07b6604..2f65fc1a0b 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; pub mod bg_erase; pub mod brush; +pub mod curve; pub mod dynamics; pub mod mixer; pub mod outline; @@ -36,6 +37,7 @@ pub use brush::{ BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MAX_BRUSH_SIZE, MAX_SCATTER, MaskMode, Pattern, PatternStyle, Pose, PresetCaptures, Scattering, SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, }; +pub use curve::ResponseCurve; pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke}; pub use tile::{GrayTile, StoredTile}; diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index ff45764980..20b809764a 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -66,11 +66,17 @@ impl Tile { } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { - let n = (TILE_SIZE * TILE_SIZE) as usize; - let mut data = vec![0u8; n * format.bytes_per_pixel()]; + let row_len = TILE_SIZE as usize * format.bytes_per_pixel(); + let mut data = vec![0u8; row_len * TILE_SIZE as usize]; if pixel.iter().any(|&b| b != 0) { - for px in data.chunks_exact_mut(pixel.len()) { - px.copy_from_slice(pixel); + // One prebuilt row copied per tile row: 256 bulk copies instead of one + // copy_from_slice per pixel. + let mut row = Vec::with_capacity(row_len); + for _ in 0..TILE_SIZE { + row.extend_from_slice(pixel); + } + for out in data.chunks_mut(row_len) { + out.copy_from_slice(&row); } } Tile { data: data.into_boxed_slice() } @@ -763,7 +769,20 @@ impl Surface { let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); let mut uniform = UniformTiles::default(); - let zero = vec![0u8; bpp]; + // The fill row: one tile row of the default pixel, so the regions a shift vacates + // fill with bulk copies (empty when the default is zero — the tile starts zeroed). + + // One row of the default pixel, so the regions a shift vacates fill with bulk copies. + let fill_row: Vec = if self.default_pixel.iter().any(|&b| b != 0) { + let mut row = Vec::with_capacity(TILE_SIZE as usize * bpp); + for _ in 0..TILE_SIZE { + row.extend_from_slice(&self.default_pixel); + } + row + } else { + Vec::new() + }; + // One tile row of the default pixel, to compare a finished tile with a row at a time. let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize); for dc in targets { @@ -783,22 +802,82 @@ impl Surface { } continue; } - // A covered target is overwritten whole, so skip filling it with the default pixel. - let mut tile = Tile::filled(&self.format, if covered { &zero } else { &self.default_pixel }); - for sc in sr.intersect(&content).tiles() { - let Some(st) = self.tiles.get(&sc) else { continue }; - let part = sc.rect().intersect(&sr); - let span = part.width() as usize * bpp; - // Offsets inside the tiles (the target's computed wide: an offset near the i32 - // limits saturates `translate`, and such rows are skipped rather than misplaced). - let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().map(|i| i * bpp); - for y in part.y0..part.y1 { - let src = at(i64::from(part.x0 - sc.tx * TILE_SIZE), i64::from(y - sc.ty * TILE_SIZE)); - let dst = at(i64::from(part.x0) + i64::from(dx) - i64::from(dr.x0), i64::from(y) + i64::from(dy) - i64::from(dr.y0)); - let (Some(src), Some(dst)) = (src, dst) else { continue }; - if let (Some(d), Some(s)) = (tile.data.get_mut(dst..dst + span), st.data.get(src..src + span)) { - d.copy_from_slice(s); + + let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; + let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); + let row_len = TILE_SIZE as usize * bpp; + // Per stored row: copy the source span that falls inside the content in as few + // memcpys as the tile grid allows, and fill the vacated edges from the default + // row — no whole-tile pre-fill, which rewrote nearly every byte twice. + for local_y in 0..i64::from(TILE_SIZE) { + let y = ty0 + local_y; + let Some(row) = tile.data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); + // The row's target columns whose SOURCE lies inside the content are copied + // (the copy loop crosses source tiles as needed); the rest stays default — + // exactly the pixels the shift vacated. + let cx0 = (i64::from(content.x0) + i64::from(dx)).max(tx0); + let cx1 = (i64::from(content.x1) + i64::from(dx)).min(tx0 + i64::from(TILE_SIZE)); + if !in_content_y || cx1 <= cx0 { + if !fill_row.is_empty() { + row.copy_from_slice(&fill_row); } + continue; + } + let left = (cx0.max(tx0) - tx0) as usize * bpp; + let copied_end = (cx1.min(tx0 + i64::from(TILE_SIZE)) - tx0) as usize * bpp; + if !fill_row.is_empty() { + if left > 0 { + row[..left].copy_from_slice(&fill_row[..left]); + } + if copied_end < row_len { + row[copied_end..].copy_from_slice(&fill_row[copied_end..]); + } + } + let mut x = cx0.max(tx0); + let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); + // Rows run over the same one or two source tiles: cache the last lookup. + let mut cached: Option<(i64, i64, &std::sync::Arc)> = None; + while x < x_end { + // The source document position this target column reads, and the source + // tile it lives in (the shift crosses source tiles as x runs). + let (sx, sy) = (x - i64::from(dx), y - i64::from(dy)); + let (tx, ty) = (sx.div_euclid(i64::from(TILE_SIZE)), sy.div_euclid(i64::from(TILE_SIZE))); + // A missing source tile inside the content holds the default pixel, not + // zeros: fill the span from the default row instead of skipping it + // (echelon's white-mask-with-sparse-tiles case). + let st = match cached.take() { + Some((ctx, cty, t)) if ctx == tx && cty == ty => { + cached = Some((ctx, cty, t)); + t + } + _ => match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => { + cached = Some((tx, ty, t)); + t + } + None => { + let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); + if !fill_row.is_empty() { + let at = (x - tx0) as usize * bpp; + let len = (span_end - x) as usize * bpp; + if let Some(d) = row.get_mut(at..at + len) { + d.copy_from_slice(&fill_row[at..at + len]); + } + } + x = span_end; + continue; + } + }, + }; + let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); + let span = (span_end - x) as usize * bpp; + let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; + let dst = (x - tx0) as usize * bpp; + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.data.get(src..src + span)) { + d.copy_from_slice(sr); + } + x = span_end; } } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked @@ -1531,3 +1610,60 @@ mod tests { assert_eq!(s.content_bounds(), Rect::new(-700, 0, 2000, 4001)); } } + +#[cfg(test)] +mod echelon_case { + use super::*; + + #[test] + fn sparse_missing_tiles_read_the_default_after_a_shift() { + let mut s = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + s.fill_rect(Rect::new(0, 0, 10, 10), &[0.0]); + s.fill_rect(Rect::new(600, 0, 610, 10), &[0.0]); + let t = s.translated(5, 0, Rect::new(0, 0, 768, 256)); + assert_eq!(t.pixel(305, 20), vec![1.0]); + } + + /// The rewrite must not change output: the misaligned path is compared against a + /// straightforward reference gather (read each pixel, write it shifted) over randomized + /// formats, default pixels, sparse tiles and shifts that cross tile boundaries. + #[test] + fn randomized_shifts_match_a_reference_gather() { + let mut rng: u64 = 0x1234_5678_9abc_def0; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + for case in 0..200u32 { + let fmt = [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8, PixelFormat::GRAYA8][(next() % 5) as usize]; + let def: Vec = (0..fmt.channels()).map(|i| if case % 3 == 0 { 0.0 } else { ((i + case as usize) % 7) as f32 / 6.0 }).collect(); + let mut s = Surface::with_default(fmt, &def); + // Sparse content: a few small patches at random positions. + for _ in 0..(1 + next() % 4) { + let x = (next() % 1600) as i32 - 800; + let y = (next() % 1200) as i32 - 600; + let w = 1 + (next() % 90) as i32; + let h = 1 + (next() % 70) as i32; + let v: Vec = (0..fmt.channels()).map(|i| ((i as u64 + next()) % 251) as f32 / 250.0).collect(); + s.fill_rect(Rect::new(x, y, x + w, y + h), &v); + } + let content = Rect::new(-900, -700, 1700, 1300); + let (dx, dy) = ((next() % 1200) as i32 - 600, (next() % 900) as i32 - 450); + let t = s.translated(dx, dy, content); + // Reference: every pixel of the moved extent reads the source at (x-dx, y-dy), + // inside the content, through the surface's default-aware reader. + for _ in 0..64 { + let x = (next() % 1500) as i32 - 750 + dx; + let y = (next() % 1100) as i32 - 550 + dy; + if !content.translate(dx, dy).contains(x, y) { + continue; + } + let want = if content.contains(x - dx, y - dy) { s.pixel(x - dx, y - dy) } else { s.default_pixel() }; + let got = t.pixel(x, y); + assert_eq!(got, want, "case {case} fmt {fmt:?} shift ({dx},{dy}) at ({x},{y})"); + } + } + } +} diff --git a/crates/raw/README.md b/crates/raw/README.md index b8c75ebff6..fda140d75b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -83,6 +83,12 @@ camera colour tables were copied. | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | +Nikon maker-note compression modes 6/10 select checked little-endian packed 12/14-bit +rows even when TIFF Compression is 34713. The modern lossy `0x44 0x40` curve uses +its smaller encoded domain while preserving sensor ADUs. Valid Nikon CropArea +coordinates take precedence over model fallbacks. HE/HE* and lossy-after-split +remain unsupported. Four pinned CC0 Z6 files cover both bit depths and both paths. + ## Development pipeline 1. Linearization table, per-position black level, scale to the white level. diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index 9ca5eff5f9..db1f8b1d74 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -33,7 +33,9 @@ //! * Lossless (`0x0096` version `0x46`): the decoded values are the samples. //! Lossy (`0x44`): they index a curve. Type 1 (`0x44 0x10`) stores the whole //! curve, type 2 (`0x44 0x20` / `0x44 0x40`) stores `n` points spaced -//! `2^bits / (n - 1)` codes apart, linearly interpolated. +//! `2^bits / (n - 1)` codes apart, linearly interpolated. Version `0x44 0x40` +//! uses a two-bit-smaller encoded domain (1025 / 4097 levels for 12 / 14-bit +//! samples), not the sensor bit depth; see Bill Claff's NEF Compression tables. //! * Values in the maker note are in the maker note's byte order. //! //! Not decoded (reported as unsupported; the embedded preview is used @@ -50,6 +52,59 @@ pub(crate) const NIKON_COMPRESSION: u32 = 34713; /// Maker note NEFLinearizationTable. pub(crate) const NIKON_LINEARIZATION_TABLE: u16 = 0x0096; +/// Packed uncompressed NEF modes 6 (12-bit) and 10 (14-bit). Measured on CC0 +/// Z6/Z7/D850 samples: little-endian bit packing, rows optionally padded to 16 +/// bytes. Selection comes from the maker note, never from the strip's length. +pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Result { + let w = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("packed NEF has no width"))? as usize; + let h = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("packed NEF has no height"))? as usize; + if !t.le || t.tag_uint(ifd, tag::BITS_PER_SAMPLE) != Some(bits) || t.tag_uint(ifd, tag::SAMPLES_PER_PIXEL).unwrap_or(1) != 1 { + return Err(RawError::unsupported("packed NEF byte order, bit depth or sample layout")); + } + limits.check(w as u64, h as u64, 2)?; + let offsets = t.tag_uints(ifd, tag::STRIP_OFFSETS); + let counts = t.tag_uints(ifd, tag::STRIP_BYTE_COUNTS); + let ([off], [len]) = (offsets.as_slice(), counts.as_slice()) else { + return Err(RawError::unsupported("packed NEF with multiple strips")); + }; + let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; + let data = unpack_little_rows(src, w, h, bits)?; + Ok(Plane { width: w, height: h, samples: 1, bits, data }) +} + +fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { + let bad = || RawError::malformed("packed NEF row layout is invalid or truncated"); + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return Err(bad()); + } + let row = w.checked_mul(bits as usize).ok_or_else(bad)?.div_ceil(8); + let padded = row.checked_add(15).ok_or_else(bad)? / 16 * 16; + let stride = if row.checked_mul(h) == Some(src.len()) { + row + } else if padded.checked_mul(h) == Some(src.len()) { + padded + } else { + return Err(bad()); + }; + let count = w.checked_mul(h).ok_or_else(bad)?; + let mut out = Vec::with_capacity(count); + let mask = (1u32 << bits) - 1; + for row in src.chunks_exact(stride) { + let mut bytes = row.iter(); + let (mut acc, mut have) = (0u32, 0u32); + for _ in 0..w { + while have < bits { + acc |= u32::from(*bytes.next().ok_or_else(bad)?) << have; + have += 8; + } + out.push((acc & mask) as u16); + acc >>= bits; + have -= bits; + } + } + Ok(out) +} + /// A code word that never occurred in any analysed sample. const UNSEEN: u8 = u8::MAX; @@ -216,7 +271,10 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result if split != 0 { return Err(RawError::unsupported(format!("Nikon \"lossy after split\" compressed NEF (split at row {split})"))); } - let range = 1usize << bits; + // The modern lossy curve has a smaller *encoded* domain while + // its output remains in sensor ADUs. Using 2^bits stretches the + // curve fourfold and puts Z6 image values below its black level. + let range = 1usize << if v1 == 0x40 { bits - 2 } else { bits }; if n - 1 > range || !range.is_multiple_of(n - 1) { return Err(RawError::unsupported(format!("Nikon compressed NEF: {n}-point curve for {bits}-bit data"))); } @@ -503,6 +561,37 @@ mod tests { assert!(parse_table(&[0x44, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], false, 12).is_err()); } + #[test] + fn modern_lossy_curve_preserves_sensor_adus_at_both_depths() { + for bits in [12u32, 14] { + for le in [false, true] { + let put = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let mut t = vec![0x44, 0x40]; + for _ in 0..4 { + t.extend(put(256)); + } + t.extend(put(257)); + let white = (1u16 << bits) - 1; + for i in 0..=256u32 { + t.extend(put(((i * 64).min(u32::from(white))) as u16)); + } + t.resize(624, 0); + let parsed = parse_table(&t, le, bits).unwrap(); + let Encoding::Lossy(curve) = &parsed.encoding else { panic!() }; + let range = 1usize << (bits - 2); + assert_eq!(curve.len(), range + 1); + assert_eq!(curve[range], white); + // Stored point 16 is 1024 ADU. It belongs to encoded code 256 + // at 14 bits, code 64 at 12 bits, independent of end-point clipping. + assert_eq!(curve[range / 16], 1024.min(white)); + let indices = vec![0, (range / 16) as i32, (range / 8) as i32, (range / 32) as i32]; + let huffman = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let src = encode(&indices, 2, huffman, parsed.seeds); + assert_eq!(decode(&src, 2, 2, bits, &parsed).unwrap(), vec![0, 1024, 2048, 512]); + } + } + } + /// A 14-bit lossless table (version 0x46) with the given seeds. fn lossless_table(seed: u16, le: bool) -> Vec { let mut t = vec![0x46, 0x30]; diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 585191b799..d71c4a905f 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -214,6 +214,19 @@ pub(crate) enum JpegLayout { /// DNG 1.7 JPEG XL compression. const JPEG_XL: u32 = 52546; +/// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated +/// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. +fn inflate_tile(src: &[u8], need: usize) -> Result> { + let mut out = vec![0u8; need]; + let mut d = flate2::Decompress::new(true); + let status = d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; + if status != flate2::Status::StreamEnd { + return Err(RawError::malformed("deflate tile is truncated or larger than its tile")); + } + out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + Ok(out) +} + /// Reads the (uncompressed, lossless-JPEG or JPEG XL) image of a raw IFD. pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result { let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize; @@ -261,10 +274,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let too_big = || RawError::malformed("deflate tile is too large"); let need = s.w.checked_mul(rows).and_then(|n| n.checked_mul(samples)).and_then(|n| n.checked_mul((bits as usize).div_ceil(8))).ok_or_else(too_big)?; - let mut out = vec![0u8; need]; - let mut d = flate2::Decompress::new(true); - d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; - out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + let out = inflate_tile(src, need)?; let v = unpack(&out, s.w, rows, samples, bits, le)?; // The TIFF predictor runs after unpacking, per row, per sample plane. match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) { @@ -459,6 +469,26 @@ mod tests { assert_eq!(c.phase(0, 0), [1, 0, 2, 1]); // GRBG } + fn zlib(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + #[test] + fn inflate_tile_requires_stream_end() { + let data: Vec = (0..64u8).collect(); + let z = zlib(&data); + assert_eq!(inflate_tile(&z, 64).unwrap(), data); + // Shorter than the tile, but a complete stream: still accepted. + assert_eq!(inflate_tile(&z, 100).unwrap(), data); + // Truncated stream. + assert!(inflate_tile(&z[..z.len() - 6], 64).is_err()); + // More decoded output than the tile holds. + assert!(inflate_tile(&z, 32).is_err()); + } + #[test] fn unpack_12_bit() { // Two 12-bit samples 0xABC, 0x123 packed MSB first. diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 4a75491f5f..25befbddea 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -78,6 +78,18 @@ fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { Some((inner, ifd)) } +/// ExifTool's public Nikon tag documentation: legacy 0x93, or a u16 at offset +/// 10 of MakerNotes0x51. The latter is used by Z6/Z7, despite TIFF Compression +/// remaining 34713 even for uncompressed sensor data. +fn nikon_compression(nikon: Option<&(Tiff<'_>, Ifd)>) -> Option { + let (t, m) = nikon?; + t.tag_uint(m, 0x0093).or_else(|| { + let b = t.raw(m.get(0x0051)?)?; + let v: [u8; 2] = b.get(10..12)?.try_into().ok()?; + Some(u32::from(if t.le { u16::from_le_bytes(v) } else { u16::from_be_bytes(v) })) + }) +} + /// Four levels listed in R, G, G, B order, placed at their 2×2 CFA positions /// (data parity). pub(crate) fn rggb_by_position(cfa: &Cfa, v: &[f64]) -> Option<[f32; 4]> { @@ -130,7 +142,12 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result nefc::read_packed(t, &raw, 12, limits)?, + Some(10) => nefc::read_packed(t, &raw, 14, limits)?, + Some(13 | 14) => return Err(RawError::unsupported("Nikon High Efficiency / High Efficiency* NEF")), + _ => nefc::read_compressed(t, &raw, nikon.as_ref(), limits)?, + } } else { read_plane(t, &raw, limits, JpegLayout::Quads)? }; @@ -195,6 +212,17 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result= 0.0 { v.round() as usize } else { 0 }; let r = Rect::new(f(*ox), f(*oy), f(*cw), f(*ch)).intersect(&full); diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/nef_packed.rs new file mode 100644 index 0000000000..f050681857 --- /dev/null +++ b/crates/raw/tests/nef_packed.rs @@ -0,0 +1,86 @@ +use photocraft_raw::testgen::{Val, nef_compressed}; +use photocraft_raw::{Limits, RawError}; + +fn packed(values: &[u16], w: usize, bits: u32, padded: bool) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let start = out.len(); + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += bits; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + if padded { + out.resize(start + (out.len() - start).div_ceil(16) * 16, 0xff); + } + } + out +} + +#[test] +fn packed_nef_uses_maker_mode_not_the_shared_tiff_compression() { + let (w, h) = (22, 5); + for (bits, mode) in [(12u32, 6u16), (14, 10)] { + let values: Vec = (0..w * h).map(|i| ((i * 83 + 11) % (1 << bits)) as u16).collect(); + for padded in [false, true] { + for big_note in [false, true] { + let strip = packed(&values, w, bits, padded); + for modern in [false, true] { + let tag = if modern { + let mut record = b"01000400\0\0".to_vec(); + record.extend(if big_note { mode.to_be_bytes() } else { mode.to_le_bytes() }); + (0x51, Val::Undefined(record)) + } else { + (0x93, Val::Short(vec![mode])) + }; + let bytes = nef_compressed(w, h, bits as u16, strip.clone(), [0, 1, 1, 2], big_note, vec![tag]); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.data, values); + } + } + } + } +} + +#[test] +fn packed_nef_rejects_truncation_wrong_depth_and_unknown_modes() { + let strip = packed(&[1000; 18 * 5], 18, 14, true); + for size in 0..strip.len() { + let bytes = nef_compressed(18, 5, 14, strip[..size].to_vec(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + // The unpadded size is another valid layout; all other truncations fail. + if size != (18 * 14usize).div_ceil(8) * 5 { + assert!(photocraft_raw::decode(&bytes, &Limits::default()).is_err()); + } + } + let bytes = nef_compressed(18, 5, 12, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + for mode in [3, 4, 9, 13, 14, 65535] { + let bytes = nef_compressed(18, 5, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![mode]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn nikon_crop_area_uses_sensor_coordinates_and_ignores_invalid_rectangles() { + let strip = packed(&[1000; 22 * 10], 22, 14, true); + for (rect, expected) in [ + ([2, 2, 18, 6], photocraft_raw::Rect::new(2, 2, 18, 6)), + ([2, 2, 22, 10], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([2, 2, 0, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([u32::MAX, 2, 18, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ] { + let bytes = nef_compressed(22, 10, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10])), (0x45, Val::Long(rect.to_vec()))]); + let sensor = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(sensor.crop, expected); + let developed = photocraft_raw::develop_sensor(&sensor, &Default::default()).unwrap(); + assert_eq!((developed.width as usize, developed.height as usize), (expected.width, expected.height)); + } +} diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/raw_corpus.rs index d90e06a6c1..afd168a38a 100644 --- a/crates/raw/tests/raw_corpus.rs +++ b/crates/raw/tests/raw_corpus.rs @@ -144,6 +144,41 @@ fn developing_produces_the_crop_in_16bit_rgb() { } } +/// These paired exposures used to decode below their recorded black levels because the +/// version 0x44 0x40 linearization curve was interpolated across the sensor's full bit range. +#[test] +fn modern_lossy_nef_preserves_brightness_at_both_depths() { + for (file, black, white) in [("DSC_0751.NEF", 1008.0, 16383.0), ("DSC_0754.NEF", 252.0, 4095.0)] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.model.as_deref(), Some("NIKON Z 6")); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(s.black.values, vec![black; 4]); + assert!(s.white.iter().all(|v| (*v - white).abs() < 100.0)); + // The same scene spans deep shadows and bright highlights at either sensor depth. + let max = s.data.iter().copied().max().unwrap(); + assert!(f32::from(max) > white * 0.9, "{file}: maximum {max}"); + let mean = s.data.iter().map(|v| f64::from(*v)).sum::() / s.data.len() as f64; + assert!(mean > f64::from(black) * 1.5, "{file}: mean {mean}"); + let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + assert!(d.rgb.iter().copied().max().unwrap() > 32768, "{file}: black development"); + } +} + +#[test] +fn packed_nef_opens_full_sensor_and_metadata_crop_at_both_depths() { + for (file, first) in [("DSC_0750.NEF", [3539, 3761, 3640, 3904]), ("DSC_0753.NEF", [866, 932, 881, 948])] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(&s.data[..4], &first); + assert_eq!(s.crop, photocraft_raw::Rect::new(8, 8, 6048, 4024)); + let d = photocraft_raw::develop_sensor(&s, &Default::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + } +} + /// The E-5 file is the documented known-unsupported case: later Olympus bodies store packed /// (compressed) sensor data. The error must say so, never panic, and the file still previews. #[test] diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index c8115c3e1b..a68d093669 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -46,7 +46,8 @@ impl Value { pub fn as_f64(&self) -> Option { match self { Value::Int(i) => Some(*i as f64), - Value::Real(r) => Some(*r), + // `inf`, `nan` and `1e999` parse as reals but are never a valid style number. + Value::Real(r) if r.is_finite() => Some(*r), _ => None, } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 55f9e0a539..e2bd223b3b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -454,7 +454,7 @@ impl Layouter { } pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout { - let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; let runs = t.char_runs(); let paras = t.paragraph_runs(); let vertical = t.orientation == Orientation::Vertical; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7909199a6f..ad5e90ae35 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -92,7 +92,7 @@ impl TextEngine { acc += r.len; at < acc })?; - let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let em = style_run.style.size_pt * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; if em.is_nan() || em <= 0.0 { return None; } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3f8ce72902..0092fcf92e 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -105,8 +105,8 @@ fn enum_value(d: &Descriptor, key: &str) -> Option { fn dnum(v: Option<&D>) -> Option { match v { - Some(D::Double(x)) => Some(*x), - Some(D::UnitFloat { value, .. }) => Some(*value), + Some(D::Double(x)) if x.is_finite() => Some(*x), + Some(D::UnitFloat { value, .. }) if value.is_finite() => Some(*value), Some(D::Integer(i)) => Some(f64::from(*i)), _ => None, } @@ -360,7 +360,7 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { }); } } - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; if let Some(e) = engine_data(&t.text) { // Without an `Ornt` key, the engine data's writing direction says it (2 = vertical). if enum_value(&t.text, "Ornt").is_none() && e.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64) == Some(2.0) { @@ -582,6 +582,8 @@ fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); + // An f32 that a huge finite f64 overflowed to infinity is dropped too. + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)) { @@ -591,18 +593,18 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s.italic = r.italic; s.postscript_name = Some(ps.clone()); } - if let Some(v) = num("FontSize") { - s.size_pt = v as f32 * k; + if let Some(v) = numk("FontSize", k) { + s.size_pt = v; } if flag("AutoLeading") == Some(false) - && let Some(v) = num("Leading") + && let Some(v) = numk("Leading", k) { - s.leading_pt = Some(v as f32 * k); + s.leading_pt = Some(v); } - s.tracking = num("Tracking").unwrap_or(0.0) as f32; - s.baseline_shift_pt = num("BaselineShift").unwrap_or(0.0) as f32 * k; - s.horizontal_scale = num("HorizontalScale").unwrap_or(1.0) as f32; - s.vertical_scale = num("VerticalScale").unwrap_or(1.0) as f32; + s.tracking = numk("Tracking", 1.0).unwrap_or(0.0); + s.baseline_shift_pt = numk("BaselineShift", k).unwrap_or(0.0); + s.horizontal_scale = numk("HorizontalScale", 1.0).unwrap_or(1.0); + s.vertical_scale = numk("VerticalScale", 1.0).unwrap_or(1.0); s.faux_bold = flag("FauxBold").unwrap_or(false); s.faux_italic = flag("FauxItalic").unwrap_or(false); s.underline = flag("Underline").unwrap_or(false); @@ -615,14 +617,17 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C } } s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics }; - s.kern = num("Kerning").filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + s.kern = numk("Kerning", 1.0).unwrap_or(0.0); s.caps = match g("FontCaps").and_then(E::as_i64) { Some(1) => Caps::SmallCaps, Some(2) => Caps::AllCaps, _ => Caps::Normal, }; if let Some(c) = g("FillColor") { - let v = arr_f(c.get("Values")); + // Positions matter (alpha, then the channels): a non-finite entry voids the colour. + let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); + let n_raw = c.get("Values").and_then(E::as_array).map_or(0, <[E]>::len); + let v = if v.len() == n_raw && v.iter().all(|x| (*x as f32).is_finite()) { v } else { Vec::new() }; let a = v.first().copied().unwrap_or(1.0) as f32; s.color = match v.len() { 5 => Color { mode: ColorMode::Cmyk, c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], alpha: a }, @@ -636,6 +641,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { align: match num("Justification").map(|v| v as i64) { Some(1) => TextAlign::Right, @@ -646,12 +652,12 @@ pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { Some(6) => TextAlign::JustifyAll, _ => TextAlign::Left, }, - first_line_indent_pt: num("FirstLineIndent").unwrap_or(0.0) as f32 * k, - start_indent_pt: num("StartIndent").unwrap_or(0.0) as f32 * k, - end_indent_pt: num("EndIndent").unwrap_or(0.0) as f32 * k, - space_before_pt: num("SpaceBefore").unwrap_or(0.0) as f32 * k, - space_after_pt: num("SpaceAfter").unwrap_or(0.0) as f32 * k, - auto_leading: num("AutoLeading").unwrap_or(1.2) as f32, + first_line_indent_pt: numk("FirstLineIndent", k).unwrap_or(0.0), + start_indent_pt: numk("StartIndent", k).unwrap_or(0.0), + end_indent_pt: numk("EndIndent", k).unwrap_or(0.0), + space_before_pt: numk("SpaceBefore", k).unwrap_or(0.0), + space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), + auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), ..Default::default() } @@ -1089,7 +1095,7 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { /// Updates (or creates) an EngineData tree for `layer`. pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E { - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; let fresh = || E::Dict(vec![("EngineDict".into(), E::dict()), ("ResourceDict".into(), E::dict())]); // The template comes from the PSD: it may not be a dictionary or may lack `EngineDict`. let mut e = match template { @@ -1334,3 +1340,33 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } + +#[cfg(test)] +mod nonfinite_tests { + use super::*; + use crate::engine_data::parse; + + #[test] + fn non_finite_style_numbers_fall_back_to_defaults() { + let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); + let s = char_style(None, &d, &[], 1.0); + let dflt = CharStyle::default(); + assert_eq!(s.size_pt, dflt.size_pt); + assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); + assert_eq!(s.color, Color::BLACK); + // A finite f64 too big for f32 is dropped as well. + let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + let d = parse(b"<< /FontSize 24.0 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); + } + + #[test] + fn infinite_dpi_does_not_write_a_zero_size() { + let layer = + TextLayer { text: "x".into(), runs: vec![TextRun { len: 1, style: CharStyle { size_pt: 12.0, ..Default::default() } }], ..Default::default() }; + let out = String::from_utf8_lossy(&crate::engine_data::write(&build_engine_data(&layer, None, f32::INFINITY))).into_owned(); + assert!(out.contains("/FontSize 12.0"), "{out}"); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index 431d020b23..bca52e38cf 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -88,6 +88,10 @@ impl Coverage { } let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) }; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); + // A finite f64 beyond f32 range becomes infinite here. + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return; + } if y1 <= 0.0 || y0 >= h { return; } diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 73c5d7f30b..0198aea97a 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -127,7 +127,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { /// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256. fn sri(hex: &str) -> Option { - if hex.len() != 64 { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); @@ -444,4 +444,12 @@ mod tests { assert_eq!(language_script(tag), want, "{tag}"); } } + + #[test] + fn sri_rejects_non_hex_digits() { + let ok = "0".repeat(64); + assert!(sri(&ok).is_some()); + let plus = format!("+f{}", "0".repeat(62)); + assert!(sri(&plus).is_none()); + } } diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index e0f3713f2e..a5c2f15e9b 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -23,6 +23,9 @@ pub struct ActionsUi { pub expanded: Vec, /// Set folders shown collapsed (Photoshop's set disclosure triangles). pub collapsed_sets: Vec, + /// Photoshop's Button Mode: each action is a coloured button, and a click plays it. + #[serde(default)] + pub button_mode: bool, #[serde(skip)] reveal_recording: Option<(usize, usize)>, /// The action whose name is being edited in place (double-click on its name). @@ -237,6 +240,24 @@ fn step_count(steps: usize, disabled: usize) -> String { } } +fn button_fill(color: &str, t: &Tokens) -> Color32 { + // Photoshop's Actions button colours. Empty uses the panel card so an uncoloured action stays readable. + match color { + "red" => Color32::from_rgb(196, 64, 64), + "orange" => Color32::from_rgb(204, 120, 40), + "yellow" => Color32::from_rgb(196, 176, 48), + "green" => Color32::from_rgb(56, 140, 72), + "blue" => Color32::from_rgb(48, 104, 176), + "violet" => Color32::from_rgb(120, 72, 168), + "gray" => t.text_dim, + _ => t.card, + } +} + +fn button_label_color(color: &str, t: &Tokens) -> Color32 { + if color.is_empty() { t.text } else { Color32::from_rgb(20, 20, 20) } +} + fn paint_check(ui: &egui::Ui, rect: Rect, on: bool, t: &Tokens) { let b = Rect::from_center_size(rect.center(), vec2(11.0, 11.0)); ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside); @@ -250,6 +271,14 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "layer.select" { + let target = params + .get("name") + .and_then(Value::as_str) + .or_else(|| params.get("layer").and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| params.get("layer").and_then(Value::as_u64).map(|id| format!("ID {id}"))); + target.map_or_else(|| label_of(id), |target| format!("{} ({target})", label_of(id))) } else if id == "actions.conditional" { // "If hasLayers: FS8-Layers / FS8-No-Layers" let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); @@ -577,6 +606,7 @@ struct Row { disabled: std::collections::BTreeSet, dialogs: std::collections::BTreeSet, set: Option, + color: String, } /// The panel's order: actions outside any set first, then each set folder (in order of first @@ -606,7 +636,14 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .list .iter() .enumerate() - .map(|(i, a)| Row { name: a.name.clone(), steps: visible_steps(app, i), disabled: a.disabled.clone(), dialogs: a.dialogs.clone(), set: a.set.clone() }) + .map(|(i, a)| Row { + name: a.name.clone(), + steps: visible_steps(app, i), + disabled: a.disabled.clone(), + dialogs: a.dialogs.clone(), + set: a.set.clone(), + color: a.color.clone(), + }) .collect(); if app .ui @@ -662,7 +699,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let mut shown_set: Option<&str> = None; for i in display_order(&rows) { - let Row { name, steps, disabled, dialogs, set } = &rows[i]; + let Row { name, steps, disabled, dialogs, set, color: swatch } = &rows[i]; // A set folder row before the first of its actions; collapsed folders hide them. if let Some(set) = set.as_deref() && shown_set != Some(set) @@ -711,9 +748,12 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if reveal == Some((i, 0)) { ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); } + let button_mode = app.ui.actions.button_mode; let sel = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step.is_none(); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, name)); - if sel { + if button_mode { + ui.painter().rect_filled(rect, 3.0, button_fill(swatch, &t)); + } else if sel { ui.painter().rect_filled(rect, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); @@ -721,14 +761,21 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(change) = drag_row(ui, &resp, DragRow { action: i, step: None }, &t) { moved = Some(change); } - let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false); - let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0)); - let pts = - if expanded { vec![tri.left_top(), tri.right_top(), tri.center_bottom()] } else { vec![tri.left_top(), tri.right_center(), tri.left_bottom()] }; - ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); - crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); + let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false) && !button_mode; + if !button_mode { + let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0)); + let pts = if expanded { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + } else { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); + crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); + } let recording_this = recording.is_some_and(|(_, r)| r == i); let nsteps = steps.len(); + let name_x = if button_mode { x0 + 8.0 } else { x0 + 44.0 }; + let name_color = if button_mode { button_label_color(swatch, &t) } else { t.text }; let name_rect = if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index == i) { let field = Rect::from_min_max(pos2(x0 + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(x0 + 120.0), rect.bottom() - 3.0)); if let Some(text) = rename_field(app, ui, i, field) { @@ -736,27 +783,33 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } field } else { - ui.painter().text(pos2(x0 + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text) + ui.painter().text(pos2(name_x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), name_color) }; - ui.painter().text( - pos2(rect.right() - 8.0, rect.center().y), - Align2::RIGHT_CENTER, - app.session - .prefs() - .shortcuts - .get(&shortcut_id(name)) - .filter(|s| !s.is_empty()) - .map_or_else(|| step_count(nsteps, disabled.len()), |key| format!("{key} · {}", step_count(nsteps, disabled.len()))), - egui::FontId::proportional(11.0), - t.text_faint, - ); + if !button_mode { + ui.painter().text( + pos2(rect.right() - 8.0, rect.center().y), + Align2::RIGHT_CENTER, + app.session + .prefs() + .shortcuts + .get(&shortcut_id(name)) + .filter(|s| !s.is_empty()) + .map_or_else(|| step_count(nsteps, disabled.len()), |key| format!("{key} · {}", step_count(nsteps, disabled.len()))), + egui::FontId::proportional(11.0), + t.text_faint, + ); + } if recording_this { ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60)); } if resp.clicked() { app.ui.actions.selected = Some(i); app.ui.actions.selected_step = None; - if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { + if button_mode { + if !resp.double_clicked() { + play_idx = Some(i); + } + } else if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { if app.ui.actions.expanded.len() <= i { app.ui.actions.expanded.resize(i + 1, false); } @@ -773,8 +826,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.close(); } } + ui.separator(); + ui.menu_button(tl!("Color"), |ui| { + for (id, label) in [ + ("", tl!("None")), + ("red", tl!("Red")), + ("orange", tl!("Orange")), + ("yellow", tl!("Yellow")), + ("green", tl!("Green")), + ("blue", tl!("Blue")), + ("violet", tl!("Violet")), + ("gray", tl!("Gray")), + ] { + if ui.selectable_label(swatch == id, label).clicked() { + let _ = app.run("actions.setColor", json!({"action": i, "color": id})); + ui.close(); + } + } + }); }); - if resp.double_clicked() { + if resp.double_clicked() && !button_mode { // Photoshop renames in place when the name itself is double-clicked. if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) { app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false }); @@ -894,6 +965,12 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); } }); + let mode_label = if app.ui.actions.button_mode { tl!("Button Mode On") } else { tl!("Button Mode") }; + let mode_button = ui.add_enabled(!recording_now, egui::Button::new(mode_label).small()).on_hover_text(tl!("Button Mode")); + mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !recording_now, mode_label)); + if mode_button.clicked() { + app.ui.actions.button_mode = !app.ui.actions.button_mode; + } }); if let Some(i) = play_idx { match app.run("actions.play", json!({"action": i})) { @@ -911,6 +988,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn selected_layer_name_is_visible_during_recording_and_after_stop() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + app.run("layer.setProps", json!({"name":"Background"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("actions.record", json!({"name":"Select photo"})).unwrap(); + app.ui.actions.expanded = vec![true]; + app.run("layer.select", json!({"layer":layer.0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + assert_eq!(step_label("layer.select", &json!({"layer":26})), "Select Layer (ID 26)"); + assert_eq!(step_label("layer.select", &json!({"layer":"Background"})), "Select Layer (Background)"); + } + #[test] fn actions_list_grows_with_panel_and_keeps_footer_visible() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1290,6 +1387,81 @@ mod tests { assert_eq!(app.session.active().unwrap().path.as_deref(), Some("out/result.psd")); } + /// An app with the real importer/exporter and writer whose Save dialogs answer `answers`. + fn disk_app(answers: Vec>) -> PhotocraftApp { + let services = crate::Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + export: Some(Box::new(|doc, path, _| { + photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + })), + write: Some(Box::new(|path, bytes| std::fs::write(path, bytes).map_err(|e| e.to_string()))), + file_dialog: Some(crate::file_dialog::fake(answers).0), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.background_jobs = false; + app + } + + /// Records Invert + File › Save on the file `a`, then plays it on the file `b` (given a + /// new layer first when `layered`). Returns the played result and whether `b` / `a` + /// changed on disk. + fn record_save_on_a_play_on_b(ext: &str, layered: bool) -> (Value, bool, bool) { + use crate::file_dialog::FileDialogAnswer::SaveTo; + let dir = std::env::temp_dir().join(format!("photocraft-rec-save-{ext}-{layered}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |n: &str| dir.join(format!("{n}.{ext}")).to_string_lossy().into_owned(); + let (a, b) = (path("a"), path("b")); + // The dialog confirms the suggested name, which is the document's own file. + let mut app = disk_app(vec![Some(SaveTo(a.clone())), Some(SaveTo(a.clone()))]); + let ctx = egui::Context::default(); + for p in [&a, &b] { + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.save_as(Some(p.clone())).unwrap(); + } + let (before_a, before_b) = (std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap()); + app.open_path(&a).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("image.adjustments.invert", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + app.run("actions.stop", json!({})).unwrap(); + std::fs::write(&a, &before_a).unwrap(); + app.open_path(&b).unwrap(); + if layered { + app.run("layer.new.layer", json!({})).unwrap(); + } + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + let r = (played, std::fs::read(&b).unwrap() != before_b, std::fs::read(&a).unwrap() != before_a); + let _ = std::fs::remove_dir_all(&dir); + r + } + + #[test] + fn recorded_save_overwrites_the_layered_file_the_action_is_played_on() { + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("psd", false); + assert!(played.get("failed").is_none(), "{played}"); + assert!(b_changed && !a_changed); + } + + #[test] + fn recorded_save_overwrites_the_flat_file_the_action_is_played_on() { + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", false); + assert!(played.get("failed").is_none(), "{played}"); + assert!(b_changed, "Save did not overwrite the file the action was played on"); + assert!(!a_changed, "Save wrote the file the action was recorded on"); + } + + #[test] + fn recorded_save_never_flattens_layers_over_a_flat_file() { + // The played-on document has layers its PNG can't keep: the step fails, nothing is written (#3806). + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", true); + assert!(played.get("failed").is_some(), "{played}"); + assert!(!b_changed && !a_changed); + } + #[test] fn set_folders_group_actions_and_collapse() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1419,4 +1591,27 @@ mod tests { assert_eq!(v["ran"], 3, "{v}"); assert!(v.get("paused").is_none()); } + + #[test] + fn button_mode_plays_the_clicked_action_and_hides_its_steps() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.session.actions.list.push(Action::new("Print", vec![("layer.new.layer".into(), json!({}))])); + app.session.actions.list[0].color = "blue".into(); + app.ui.actions.expanded = vec![true]; + app.ui.actions.button_mode = true; + let mut h = Harness::builder().with_size(vec2(420.0, 280.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 0, "button mode hides the step list"); + let before = h.state().session.active().unwrap().doc.layers.len(); + h.get_by_label("Print").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1, "a click plays the action"); + h.get_by_label("Button Mode On").click(); + h.run_steps(2); + assert!(!h.state().ui.actions.button_mode); + assert!(h.query_by_label("Button Mode").is_some()); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 1, "the step list returns when Button Mode is turned off"); + } } diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 5f43348507..cabf9fbded 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -146,7 +146,7 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, kind); let defaults = photocraft_engine::adjust_params::default_for(kind, mode).ok()?; let mut fields = Map::new(); fields.insert("__command".into(), json!(command)); @@ -184,19 +184,49 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask +/// selection — both ways of selecting a mask must give the same dropdown (#5335). +fn isolated(app: &PhotocraftApp) -> Option { + let mask = app.layer_mask_targeted(); + app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask)) +} + +/// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or +/// Quick Mask holds gray levels whatever the document's mode, so its curve is a neutral one +/// and the fields carry no Lab or CMYK records (#5335). +fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { + let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { + photocraft_doc::ColorMode::Grayscale + } else { + mode + } +} + /// Dialog body: the kind's editor over the fields, then the Preview checkbox. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, &kind); if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); } let mut values = crate::filter_dialog::params_of(fields); let active = app.session.active().and_then(|s| s.active_layer); - let gray = app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); + let isolated_curve = isolated(app); + // Only Curves gets an isolated single-channel target here; Levels keeps its usual fields. + let gray_plane = (kind == "curves").then(|| isolated_curve.as_ref().and_then(|c| c.gray)).flatten(); + let gray = gray_plane.is_some() || app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); let hist = match active { - Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), + Some(id) if adjust_editors::needs_histogram(&kind) => { + // A mask or saved channel's curve edits that plane's own levels: the histogram + // describes it, not the colour layers beneath (#5335). + let source = match gray_plane { + Some(surface) => HistSource::Channel { layer: id, surface }, + None => HistSource::Layer(id), + }; + Some(tone::histograms(app, source, adjust_editors::space_of(&values))) + } _ => None, }; let cx = EditorCx { @@ -207,6 +237,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map(dialog_mem("curves").with("curves-graph"))).unwrap(); + assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}"); + assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}"); + assert!(window.contains_rect(graph)); + for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { + assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window"); + } + } + } + } + } + #[test] fn cancel_leaves_the_document_untouched() { let mut harness = @@ -738,4 +793,89 @@ mod tests { assert_eq!(channel_points(&app, "red"), red, "pointer up does not sample again"); assert!(picker_armed(&app)); } + + /// #5335: a targeted mask, saved channel or colour component is the only Channel popup entry. + #[test] + fn curves_channel_popup_lists_only_the_targeted_channel() { + let mut app = app_with_image(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n.contains("Mask")), "{names:?}"); + assert!(!names.iter().any(|n| n == "RGB" || n == "Red" || n == "Green" || n == "Blue"), "{names:?}"); + } + + /// #5335: selecting the mask in the Layers panel gives the same Curves dropdown as selecting + /// it in the Channels panel. + #[test] + fn curves_channel_popup_lists_only_the_mask_selected_in_the_layers_panel() { + let mut app = app_with_image(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; // clicking the mask thumbnail in the Layers panel + assert!(app.layer_mask_targeted(), "the Layers panel mask thumbnail is targeted"); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert_eq!(names.len(), 1, "only the mask: {names:?}"); + assert!(names[0].contains("Mask"), "{names:?}"); + } + + /// #5335: in a Lab document, Curves over a gray target keeps neutral fields: no Lab records + /// to overwrite the mask's own curve or pick the wrong tone space. + #[test] + fn curves_on_a_gray_target_seeds_neutral_fields_in_a_lab_document() { + for via_layers_panel in [false, true] { + let mut app = app_in_mode("lab"); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + if via_layers_panel { + app.ui.mask_target = true; + } else { + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + } + let id = open(&mut app, "image.adjustments.curves").expect("opens"); + let values = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields.clone()); + assert!(values.get("points").is_some(), "{values:?}"); + for key in ["lightness", "a", "b", "cyan", "magenta", "yellow", "black"] { + assert!(values.get(key).is_none(), "{key} in a gray target's curves: {values:?}"); + } + } + } + + /// #5335: in Italian the built-in labels translate, but a channel the user named "Green" + /// stays exactly "Green". + #[test] + fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { + let it = crate::i18n::Lang::from_code("it").expect("it registered"); + assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label"); + let _lang = crate::i18n::language_scope(it); + let mut app = app_with_image(); + app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}"); + assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}"); + // A targeted colour component keeps its translated built-in label. + let mut colour = app_with_image(); + colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let mut h = curves_harness(colour); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}"); + } + + /// Curves open in a harness over `app`. + fn curves_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + h + } + + /// The Channel popup's selected value (its option labels are what it shows). + fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .filter_map(|n| n.value().map(|s| s.to_string())) + .collect() + } } diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 2678cc65f2..87a81b8c35 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -92,6 +92,8 @@ pub struct EditorCx { /// Preferences ▸ Interface ▸ Dynamic Color Sliders: colour tracks on the colour sliders /// (off: the plain track). pub dynamic_color_sliders: bool, + /// Image › Curves with a single Channels-panel target: that channel only (#5335). + pub isolated_curve: Option, } /// The Levels/Curves channel space the values address. @@ -291,14 +293,26 @@ pub fn tone_channels(space: ToneSpace, gray: bool) -> &'static [ToneChannel] { } fn channel_picker(ui: &mut egui::Ui, id: egui::Id, chans: &[ToneChannel], text: &str) -> usize { + let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); + channel_picker_opts(ui, id, &opts, text, true) +} + +/// The popup, with `translate` for whether the built-in labels follow the interface language; +/// a user-typed channel name is shown exactly as entered (#5335). +fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)], text: &str, translate: bool) -> usize { let mut ch: usize = ui.data(|d| d.get_temp(id)).unwrap_or(0); - if ch >= chans.len() { + if ch >= opts.len() { ch = 0; } ui.horizontal(|ui| { label(ui, text); - let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); - widgets::dropdown(ui, &format!("{id:?}-ch"), &mut ch, &opts, 110.0); + let width = if opts.len() == 1 { 160.0 } else { 110.0 }; + let key = format!("{id:?}-ch"); + if translate { + widgets::dropdown(ui, &key, &mut ch, opts, width); + } else { + widgets::dropdown_literal(ui, &key, &mut ch, opts, width); + } }); ui.data_mut(|d| d.insert_temp(id, ch)); ch @@ -428,11 +442,20 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let chans = tone_channels(space, cx.gray); let state_id = cx.mem.with("curves"); let mut st: CurveUi = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); - let ch = channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")); + let isolated = cx.isolated_curve.as_ref(); + let ch = if let Some(iso) = isolated { + channel_picker_opts(ui, cx.mem.with("curves-ch"), &[(0, iso.name.as_str())], tl!("Channel:"), iso.translate) + } else { + channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")) + }; if ch != st.channel { st = CurveUi { channel: ch, ..Default::default() }; } - let chan = chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }); + let chan = + if let Some(iso) = isolated { ToneChannel { key: iso.key, label: "" } } else { chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }) }; + let overlay_others = isolated.is_none(); + let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none() && ch != 0); + let hist_i = isolated.map_or(ch, |c| c.hist); let key = curve_key(&chan); let mut e = curves_mode_row(ui, v, key, cx.gray); let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); @@ -477,7 +500,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let p = ui.painter_at(full.expand(2.0)); p.rect_filled(graph, 0.0, t.field); if let Some(h) = &cx.hist { - tone::draw_histogram(&p, graph, &h[ch], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); + tone::draw_histogram(&p, graph, &h[hist_i], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); } for i in 1..4 { let f = i as f32 / 4.0; @@ -510,7 +533,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let line: Vec = map.iter().enumerate().map(|(k, o)| to_scr([k as f32 / last * 255.0, *o])).collect(); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; - if ch == 0 { + if overlay_others && ch == 0 { // Photoshop overlays the edited channel curves on the composite. for c in chans.iter().skip(1) { if pencil { @@ -526,7 +549,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } } } - let color = if ch == 0 { t.text } else { bar_color(&chan, &t) }; + let color = if tinted { bar_color(&chan, &t) } else { t.text }; if pencil { draw_map(&map, color, 1.5); } else { @@ -1623,6 +1646,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj gradients: gradient_presets(app, kind), dialog: false, dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders, + isolated_curve: None, }; if matches!(kind, "levels" | "curves") { crate::layer_picker::controls(app, ui, id); @@ -1676,6 +1700,7 @@ mod tests { gradients: Vec::new(), dialog: false, dynamic_color_sliders: dynamic, + isolated_curve: None, }; let stops = [Color32::BLACK, Color32::RED]; assert_eq!(track_stops(&cx(true), &stops), Some(&stops[..])); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 9c39088bef..d8c6e7953a 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -566,6 +566,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { gradients: Vec::new(), dialog: false, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, "gradientMap", values, &cx); }, @@ -774,6 +775,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta gradients: Vec::new(), dialog: true, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, kind, values, &cx); }, diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 6b996c3f14..0b12c36127 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -238,20 +238,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> true } -/// The resize pointer for handle (`hx`, `hy`), or none away from the selected board's handles. -pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { +/// The resize pointer for handle (`hx`, `hy`) as the view shows it, or none away from the +/// selected board's handles. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2]) -> Option { let (hx, hy) = match app.ui.artboard_tool.drag { Some(ArtboardDrag::Resize { hx, hy, .. }) => (hx, hy), Some(_) => return None, None if plus_at(app, p).is_some() => return Some(CursorIcon::PointingHand), None => handle_at(app, p).map(|h| (h.2, h.3))?, }; - Some(match (hx, hy) { - (_, 0) => CursorIcon::ResizeHorizontal, - (0, _) => CursorIcon::ResizeVertical, - _ if hx == hy => CursorIcon::ResizeNwSe, - _ => CursorIcon::ResizeNeSw, - }) + // (hx, hy) is the handle's outward direction: -1, 0 or 1 on each axis. + Some(crate::tool_cursor::resize([f64::from(hx), f64::from(hy)], xf)) } /// The selected board's handles, its "+" buttons, and the board a gesture in progress would leave. @@ -388,6 +385,24 @@ mod tests { assert_eq!(boards(&app).len(), 1); } + /// The handles' resize arrows turn with a rotated view: on a view turned 90°, the right edge's + /// handle resizes up and down on screen. + #[test] + fn resize_cursors_turn_with_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let mut app = app(); + drag(&mut app, [20.0, 30.0], [120.0, 110.0]); + // The right edge, the top edge, the top-left and the top-right corner. + let handles = [[120.0, 70.0], [70.0, 30.0], [20.0, 30.0], [120.0, 30.0]]; + for (rotation, want) in [(0.0, [H, V, NwSe, NeSw]), (90.0, [V, H, NeSw, NwSe])] { + let view_index = app.session.active_index().unwrap(); + app.ui.views[view_index].rotation = rotation; + let xf = ViewXform::active(&app).unwrap(); + let got = handles.map(|p| cursor(&app, &xf, p)); + assert_eq!(got, want.map(Some), "view turned {rotation}°"); + } + } + /// #3033: a click on the selected board's right "+" adds a board of its size a gap to the right; /// ⇧ on a corner keeps the board's proportions. #[test] diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index e054ffe2a6..df5d467e10 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -163,3 +163,47 @@ fn closing_the_document_stops_its_redraw() { g.harness.step(); assert!(g.harness.state().redraws.is_empty()); } + +#[test] +fn hiding_a_large_layer_over_the_gpu_budget_redraws_in_the_background() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + // A layer covering most, but not all, of the document: hiding it damages a large rect that + // isn't the whole canvas. + let app = g.harness.state_mut(); + app.run("file.new", json!({"width": 5000, "height": 2000, "background": "#336699"})).expect("new"); + app.run("layer.new.layer", json!({})).expect("layer"); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4800, "height": 1800})).expect("select"); + app.run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + app.run("select.deselect", json!({})).expect("deselect"); + let (doc, layer) = { + let st = app.session.active().expect("document"); + (st.doc.id, st.active_layer.expect("layer")) + }; + settle(&mut g); + assert!(g.harness.state().perf.last_refresh.starts_with("gpu"), "the document fits the GPU: {}", g.harness.state().perf.last_refresh); + // A budget the pages of one 2048 px page cell fit (about 33 MB here) but not those of the + // whole width (80 MB), as on a multi-gigabyte PSB. Damage refreshes keep it: `gpu_budget` + // recomputes it for full ones only. A small edit still draws on the GPU at once. + g.harness.state_mut().run("layer.new.layer", json!({})).expect("layer"); + g.harness.step(); + g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 64, "height": 64})).expect("select"); + g.harness.step(); + g.observer.set_memory_budget(48 << 20); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + g.harness.step(); + assert_eq!(g.harness.state().perf.last_refresh, "gpu-rect", "{:?}", g.harness.state().perf.gpu_fallback); + g.harness.state_mut().run("select.deselect", json!({})).expect("deselect"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); + g.observer.set_memory_budget(48 << 20); + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide"); + let damage = g.harness.state().session.active().expect("document").last_damage.expect("damage"); + assert!(damage != Rect::new(0, 0, 5000, 2000) && damage.width() as u64 * damage.height() as u64 >= crate::gpu_canvas::DEFER_PX, "{damage:?}"); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "not composited inline: {}", g.harness.state().perf.last_refresh); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + assert_eq!(g.harness.state().perf.gpu_fallback.as_deref(), Some(crate::gpu_canvas::OVER_BUDGET)); + assert_matches_synchronous(&mut g, doc); +} diff --git a/crates/ui-egui/src/blur_pins.rs b/crates/ui-egui/src/blur_pins.rs index 6a90d9910a..e368abf659 100644 --- a/crates/ui-egui/src/blur_pins.rs +++ b/crates/ui-egui/src/blur_pins.rs @@ -1,13 +1,26 @@ -//! Filter › Blur Gallery › Field Blur… as Photoshop's workspace (#3373): pins on the image, each -//! with its own blur, interpolated between them. While the dialog is open the image under it takes -//! the pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on -//! the selected pin's ring turns its blur up or down (one turn = the full 0–500 px; the arc shows -//! the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's -//! Blur and the Preview switch; OK runs `filter.blurGallery.fieldBlur` with the `pins`. +//! Filter › Blur Gallery as Photoshop's workspace (#3373): Field Blur, Iris Blur, Tilt-Shift and +//! Spin Blur are edited on the image. While the dialog is open the image under it takes the +//! pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on the +//! selected pin's ring turns its amount up or down (one turn = the full range; the arc shows it). +//! Delete or Backspace removes the selected pin. The dialog shows the selected pin's amount and the +//! Preview switch; OK runs the filter. //! -//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` -//! (`[{x, y, blur}]`, x and y as fractions of the filter's bounds) and `__selected`. The bounds are -//! the engine's: the selection's when there is one, else the canvas. +//! - **Field Blur** interpolates the pins' blur between them. +//! - **Iris Blur**: a sharp superellipse, blurred outside. The selected pin shows Photoshop's +//! handles: four on the outline (drag to resize that axis and turn the shape), a square at its +//! corner (drag out to round it towards a rectangle) and four dots inside (the sharp core, where +//! the blur starts). +//! - **Spin Blur**: an ellipse whose content turns about its centre; the outline handles resize and +//! turn it, and the ring sets the blur angle (one turn = 360°). +//! - **Tilt-Shift**: one sharp band (the engine has a single one, so a click adds nothing). The +//! solid lines bound the sharp band, the dashed lines the transition, and the dots on the solid +//! lines turn it. +//! +//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` as +//! the command takes them (x and y as fractions of the filter's bounds, radii as fractions of their +//! shorter side) and `__selected`; Tilt-Shift keeps the command's own `centerX`, `centerY`, +//! `angle`, `focus`, `transition` and `blur`. The bounds are the engine's: the selection's when +//! there is one, else the canvas. use egui::{Color32, Pos2, Stroke, vec2}; use serde_json::{Map, Value, json}; @@ -16,8 +29,13 @@ use crate::PhotocraftApp; use crate::canvas::ViewXform; pub const COMMAND: &str = "filter.blurGallery.fieldBlur"; -/// Field Blur's range, in pixels. +pub const IRIS: &str = "filter.blurGallery.irisBlur"; +pub const SPIN: &str = "filter.blurGallery.spinBlur"; +pub const TILT: &str = "filter.blurGallery.tiltShift"; +/// The blur range, in pixels. const MAX_BLUR: f64 = 500.0; +/// Spin Blur's blur angle range, in degrees. +const MAX_SPIN: f64 = 360.0; /// Photoshop's first pin. const DEFAULT_BLUR: f64 = 15.0; /// More pins than anyone places by hand; keeps the params and the per-pixel loop bounded. @@ -27,13 +45,129 @@ const DOT: f32 = 5.0; const GRAB: f32 = 9.0; const RING: f32 = 24.0; const RING_BAND: f32 = 5.0; +/// Screen size (points) of a shape handle. +const HANDLE: f32 = 4.5; +/// How near (points) a press must be to a Tilt-Shift line to drag it. +const LINE_GRAB: f32 = 5.0; +/// Radii, as the commands bound them. +const MIN_RADIUS: f64 = 0.01; +const MAX_FEATHER: f64 = 0.99; -pub fn owns(fields: &Map) -> bool { - fields.get("__command").and_then(Value::as_str) == Some(COMMAND) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Field, + Iris, + Spin, + Tilt, } -/// Turn a freshly opened Field Blur dialog into the workspace: one pin in the middle of the -/// filter's bounds with the remembered blur, selected. +impl Kind { + /// The amount the ring and the dialog set: blur in pixels, or Spin's blur angle in degrees. + fn max(self) -> f64 { + if self == Kind::Spin { MAX_SPIN } else { MAX_BLUR } + } + + /// The dialog field the selected pin's amount is mirrored to (remembered for next time). + fn amount_key(self) -> &'static str { + if self == Kind::Spin { "blurAngle" } else { "blur" } + } +} + +fn kind(fields: &Map) -> Option { + match fields.get("__command").and_then(Value::as_str) { + Some(COMMAND) => Some(Kind::Field), + Some(IRIS) => Some(Kind::Iris), + Some(SPIN) => Some(Kind::Spin), + Some(TILT) => Some(Kind::Tilt), + _ => None, + } +} + +pub fn owns(fields: &Map) -> bool { + kind(fields).is_some() +} + +/// The Blur Gallery filters that open as this workspace. +pub fn handles(command: &str) -> bool { + matches!(command, COMMAND | IRIS | SPIN | TILT) +} + +/// An Iris or Spin pin's shape, as the command takes it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Iris { + /// Radii as fractions of the bounds' shorter side. + pub rx: f64, + pub ry: f64, + /// Degrees, clockwise on screen. + pub angle: f64, + /// 0 = ellipse … 100 = rounded rectangle (Iris only). + pub roundness: f64, + /// Where the blur starts, as a fraction of the shape (Iris only). + pub feather: f64, +} + +impl Default for Iris { + /// Iris Blur's defaults. + fn default() -> Self { + Iris { rx: 0.35, ry: 0.25, angle: 0.0, roundness: 0.0, feather: 0.5 } + } +} + +impl Iris { + /// Spin Blur's defaults: a circle. + pub fn spin() -> Self { + Iris { rx: 0.3, ry: 0.3, ..Iris::default() } + } + + fn clean(self) -> Self { + let f = |v: f64, d: f64| if v.is_finite() { v } else { d }; + Iris { + rx: f(self.rx, 0.35).clamp(MIN_RADIUS, 1.0), + ry: f(self.ry, 0.25).clamp(MIN_RADIUS, 1.0), + angle: (f(self.angle, 0.0) + 180.0).rem_euclid(360.0) - 180.0, + roundness: f(self.roundness, 0.0).clamp(0.0, 100.0), + feather: f(self.feather, 0.5).clamp(0.0, MAX_FEATHER), + } + } + + /// The superellipse exponent the engine uses (2 = ellipse … 8). + fn exponent(self) -> f64 { + 2.0 + self.roundness / 100.0 * 6.0 + } +} + +/// Tilt-Shift's band: its angle, and the sharp band's and the transition's widths beyond the +/// centre line, as fractions of the bounds' shorter side. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tilt { + /// Degrees, −90…90; 0 is a horizontal band, positive turns it anticlockwise on screen. + pub angle: f64, + pub focus: f64, + pub transition: f64, +} + +impl Tilt { + fn clean(self) -> Self { + let f = |v: f64, d: f64| if v.is_finite() { v } else { d }; + // The band is the same turned half a turn: keep the command's −90…90. + let mut angle = (f(self.angle, 0.0) + 180.0).rem_euclid(360.0) - 180.0; + if angle > 90.0 { + angle -= 180.0; + } else if angle < -90.0 { + angle += 180.0; + } + Tilt { angle, focus: f(self.focus, 0.1).clamp(0.0, 1.0), transition: f(self.transition, 0.15).clamp(0.01, 1.0) } + } + + /// The unit normal of the band (the engine's), and its direction along the band. + fn axes(self) -> ([f64; 2], [f64; 2]) { + let (s, c) = self.angle.to_radians().sin_cos(); + ([s, c], [c, -s]) + } +} + +/// Turn a freshly opened Blur Gallery dialog into the workspace: one pin in the middle of the +/// filter's bounds with the remembered settings, selected. pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { reset(fields); let bounds = app.session.active().map(|st| (st.doc.id, st.doc.bounds(), st.doc.selection.clone())); @@ -43,15 +177,33 @@ pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { } } -/// One pin in the middle with the dialog's blur, selected. Alt+Cancel (Reset) runs this alone, so -/// the bounds captured on opening stay. +/// One pin in the middle with the dialog's settings, selected. Alt+Cancel (Reset) runs this +/// alone, so the bounds captured on opening stay. pub fn reset(fields: &mut Map) { - let blur = fields.get("blur").and_then(Value::as_f64).filter(|b| b.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); - fields.remove("centerX"); - fields.remove("centerY"); - fields.insert("blur".into(), json!(blur)); - fields.insert("pins".into(), json!([{"x": 0.5, "y": 0.5, "blur": blur}])); - fields.insert("__selected".into(), json!(0)); + let Some(k) = kind(fields) else { return }; + let num = |key: &str, d: f64| fields.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d); + let amount = num(k.amount_key(), DEFAULT_BLUR).clamp(0.0, k.max()); + let shape = |d: Iris| Iris { rx: num("radiusX", d.rx), ry: num("radiusY", d.ry), angle: num("angle", d.angle), ..d }; + let pin = match k { + Kind::Field => Pin { x: 0.5, y: 0.5, blur: amount, iris: None, tilt: None }, + Kind::Iris => { + let iris = Iris { roundness: num("roundness", 0.0), feather: num("feather", 0.5), ..shape(Iris::default()) }.clean(); + Pin { x: 0.5, y: 0.5, blur: amount, iris: Some(iris), tilt: None } + } + Kind::Spin => Pin { x: 0.5, y: 0.5, blur: amount, iris: Some(shape(Iris::spin()).clean()), tilt: None }, + Kind::Tilt => { + let tilt = Tilt { angle: num("angle", 0.0), focus: num("focus", 0.1), transition: num("transition", 0.15) }.clean(); + Pin { x: num("centerX", 0.5).clamp(0.0, 1.0), y: num("centerY", 0.5).clamp(0.0, 1.0), blur: amount, iris: None, tilt: Some(tilt) } + } + }; + // The pins replace a single shape's params (Tilt-Shift keeps its own). + if k != Kind::Tilt { + for key in ["centerX", "centerY", "radiusX", "radiusY", "angle", "roundness", "feather"] { + fields.remove(key); + } + } + set_pins(fields, &[pin]); + select(fields, Some(0)); } /// Where the dialog first opens (offset from the window's top-left): at the right edge, beside the @@ -61,26 +213,73 @@ pub fn first_place(fields: &Map, ctx: &egui::Context) -> Option, + pub tilt: Option, } /// The pins in `fields`, cleaned (finite, in range, at most [`MAX_PINS`]). pub fn pins(fields: &Map) -> Vec { + let Some(k) = kind(fields) else { return Vec::new() }; + let num = |v: &Value, key: &str, d: f64| v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); + if k == Kind::Tilt { + // Tilt-Shift's one band is the command's own params. + let f = Value::Object(fields.clone()); + let tilt = Tilt { angle: num(&f, "angle", 0.0), focus: num(&f, "focus", 0.1), transition: num(&f, "transition", 0.15) }.clean(); + let (x, y) = (num(&f, "centerX", 0.5).clamp(0.0, 1.0), num(&f, "centerY", 0.5).clamp(0.0, 1.0)); + return vec![Pin { x, y, blur: num(&f, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR), iris: None, tilt: Some(tilt) }]; + } let Some(list) = fields.get("pins").and_then(Value::as_array) else { return Vec::new() }; - let num = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); list.iter() .take(MAX_PINS) - .map(|v| Pin { x: num(v, "x", 0.5).clamp(0.0, 1.0), y: num(v, "y", 0.5).clamp(0.0, 1.0), blur: num(v, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR) }) + .map(|v| Pin { + x: num(v, "x", 0.5).clamp(0.0, 1.0), + y: num(v, "y", 0.5).clamp(0.0, 1.0), + blur: num(v, k.amount_key(), DEFAULT_BLUR).clamp(0.0, k.max()), + iris: match k { + Kind::Iris | Kind::Spin => { + let d = if k == Kind::Spin { Iris::spin() } else { Iris::default() }; + let iris = Iris { rx: num(v, "radiusX", d.rx), ry: num(v, "radiusY", d.ry), angle: num(v, "angle", d.angle), ..d }; + let iris = if k == Kind::Iris { + Iris { roundness: num(v, "roundness", d.roundness), feather: num(v, "feather", d.feather), ..iris } + } else { + iris + }; + Some(iris.clean()) + } + _ => None, + }, + tilt: None, + }) .collect() } fn set_pins(fields: &mut Map, pins: &[Pin]) { - let list: Vec = pins.iter().map(|p| json!({"x": p.x, "y": p.y, "blur": p.blur})).collect(); + let Some(k) = kind(fields) else { return }; + if k == Kind::Tilt { + let Some(p) = pins.first() else { return }; + let t = p.tilt.unwrap_or(Tilt { angle: 0.0, focus: 0.1, transition: 0.15 }); + for (key, v) in [("centerX", p.x), ("centerY", p.y), ("blur", p.blur), ("angle", t.angle), ("focus", t.focus), ("transition", t.transition)] { + fields.insert(key.into(), json!(v)); + } + return; + } + let list: Vec = pins + .iter() + .map(|p| match (k, p.iris) { + (Kind::Iris, Some(s)) => { + json!({"x": p.x, "y": p.y, "blur": p.blur, "radiusX": s.rx, "radiusY": s.ry, "angle": s.angle, "roundness": s.roundness, "feather": s.feather}) + } + (Kind::Spin, Some(s)) => json!({"x": p.x, "y": p.y, "blurAngle": p.blur, "radiusX": s.rx, "radiusY": s.ry, "angle": s.angle}), + _ => json!({"x": p.x, "y": p.y, "blur": p.blur}), + }) + .collect(); fields.insert("pins".into(), Value::Array(list)); } @@ -92,9 +291,9 @@ pub fn selected(fields: &Map) -> Option { fn select(fields: &mut Map, i: Option) { fields.insert("__selected".into(), i.map_or(Value::Null, |i| json!(i))); - // The dialog's Blur (remembered for the next Field Blur) follows the selected pin. - if let Some(p) = i.and_then(|i| pins(fields).get(i).copied()) { - fields.insert("blur".into(), json!(p.blur)); + // The dialog's amount (remembered for the next time) follows the selected pin. + if let (Some(k), Some(p)) = (kind(fields), i.and_then(|i| pins(fields).get(i).copied())) { + fields.insert(k.amount_key().into(), json!(p.blur)); } } @@ -106,6 +305,11 @@ fn bounds(fields: &Map) -> Option<[f64; 4]> { (r[2] > r[0] && r[3] > r[1]).then_some(r) } +/// The bounds' shorter side, which radii and Tilt-Shift widths are fractions of. +fn short_side(b: [f64; 4]) -> f64 { + (b[2] - b[0]).min(b[3] - b[1]).max(1.0) +} + fn to_doc(b: [f64; 4], p: Pin) -> [f64; 2] { [b[0] + p.x * (b[2] - b[0]), b[1] + p.y * (b[3] - b[1])] } @@ -114,8 +318,74 @@ fn from_doc(b: [f64; 4], d: [f64; 2]) -> (f64, f64) { (((d[0] - b[0]) / (b[2] - b[0])).clamp(0.0, 1.0), ((d[1] - b[1]) / (b[3] - b[1])).clamp(0.0, 1.0)) } -/// Remove the selected pin; the one before it (or the new first) is selected. +/// A shaped pin's local point `(u, v)` (1 = on the outline along that axis) in document pixels. +fn iris_point(b: [f64; 4], p: Pin, s: Iris, u: f64, v: f64) -> [f64; 2] { + let c = to_doc(b, p); + let ss = short_side(b); + let (x, y) = (u * s.rx * ss, v * s.ry * ss); + let (sin, cos) = s.angle.to_radians().sin_cos(); + [c[0] + x * cos - y * sin, c[1] + x * sin + y * cos] +} + +/// A document point in a shaped pin's local coordinates (the engine's `u`, `v`). +fn iris_local(b: [f64; 4], p: Pin, s: Iris, d: [f64; 2]) -> (f64, f64) { + let c = to_doc(b, p); + let ss = short_side(b); + let (dx, dy) = (d[0] - c[0], d[1] - c[1]); + let (sin, cos) = s.angle.to_radians().sin_cos(); + ((dx * cos + dy * sin) / (s.rx * ss), (-dx * sin + dy * cos) / (s.ry * ss)) +} + +/// The superellipse's diagonal point `t` (on the shape at (t, t)): 2^(−1/m). +fn diagonal(s: Iris) -> f64 { + 0.5f64.powf(1.0 / s.exponent()) +} + +/// A shaped pin's outline handles (along its axes), Iris's roundness square (upper right) and its +/// four feather dots (on the diagonals), in local coordinates. +fn axis_handles() -> [(f64, f64); 4] { + [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] +} + +fn round_handle(s: Iris) -> (f64, f64) { + let t = diagonal(s); + (t, -t) +} + +fn feather_handles(s: Iris) -> [(f64, f64); 4] { + let k = s.feather * diagonal(s); + [(k, k), (-k, k), (-k, -k), (k, -k)] +} + +/// The point of the Tilt-Shift band at `offset` pixels across it (along its normal) and `along` +/// pixels along it from the centre, in document pixels. +fn tilt_point(b: [f64; 4], p: Pin, t: Tilt, offset: f64, along: f64) -> [f64; 2] { + let c = to_doc(b, p); + let (n, d) = t.axes(); + [c[0] + n[0] * offset + d[0] * along, c[1] + n[1] * offset + d[1] * along] +} + +/// How far a document point is across the band from its centre line, in pixels (signed). +fn tilt_offset(b: [f64; 4], p: Pin, t: Tilt, d: [f64; 2]) -> f64 { + let c = to_doc(b, p); + let (n, _) = t.axes(); + (d[0] - c[0]) * n[0] + (d[1] - c[1]) * n[1] +} + +/// A Tilt-Shift turn handle: one on each solid line, beside the pin's ring (so it never sits on +/// the ring, however narrow the band). `side` is +1 or −1. +pub fn tilt_turn_handle(b: [f64; 4], p: Pin, t: Tilt, xf: &ViewXform, side: f64) -> [f64; 2] { + let ss = short_side(b); + let scale = f64::from(xf.zoom_x()).max(1e-6); + tilt_point(b, p, t, side * t.focus * ss, f64::from(RING + 18.0) / scale) +} + +/// Remove the selected pin; the one before it (or the new first) is selected. Tilt-Shift's one +/// band stays. pub fn remove_selected(fields: &mut Map) -> bool { + if kind(fields) == Some(Kind::Tilt) { + return false; + } let Some(i) = selected(fields) else { return false }; let mut list = pins(fields); list.remove(i); @@ -124,16 +394,18 @@ pub fn remove_selected(fields: &mut Map) -> bool { true } -/// The dialog body: the selected pin's Blur, how to place pins, and Preview. +/// The dialog body: the selected pin's amount, how to edit the pins, and Preview. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = crate::theme::Tokens::get(ui.ctx()); + let k = kind(f).unwrap_or(Kind::Field); match selected(f) { Some(i) => { let mut list = pins(f); if let Some(pin) = list.get_mut(i) { let mut v = pin.blur as f32; - if crate::widgets::slider_row(ui, tl!("Blur"), &mut v, 0.0..=MAX_BLUR as f32, "px", None).changed() { - pin.blur = f64::from(v.round()).clamp(0.0, MAX_BLUR); + let (label, unit) = if k == Kind::Spin { (tl!("Blur Angle"), "°") } else { (tl!("Blur"), "px") }; + if crate::widgets::slider_row(ui, label, &mut v, 0.0..=k.max() as f32, unit, None).changed() { + pin.blur = f64::from(v.round()).clamp(0.0, k.max()); set_pins(f, &list); select(f, Some(i)); } @@ -144,14 +416,26 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } ui.add_space(4.0); - ui.add( - egui::Label::new( - egui::RichText::new(tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")) - .color(t.text_dim) - .size(11.5), - ) - .wrap(), - ); + let hint = |ui: &mut egui::Ui, text: &str| ui.add(egui::Label::new(egui::RichText::new(text).color(t.text_dim).size(11.5)).wrap()); + if k == Kind::Tilt { + hint( + ui, + tl!( + "Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it." + ), + ); + } else { + hint(ui, tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")); + } + if k == Kind::Iris { + hint( + ui, + tl!("Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts."), + ); + } + if k == Kind::Spin { + hint(ui, tl!("Drag the handles on the outline to resize and turn it; the ring sets the blur angle.")); + } // Delete removes the selected pin, unless a field is being typed in. let typing = ui.ctx().memory(|m| m.focused().is_some()); if !typing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) { @@ -165,30 +449,79 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } -/// What a press at screen point `at` grabs: the selected pin's ring, a pin, or nothing (a new pin). +/// What a press at screen point `at` grabs: one of the selected pin's handles (axis 0–3 = +u, +v, +/// −u, −v; Tilt-Shift's turn dots and lines), its ring, a pin, or nothing (a new pin). #[derive(Clone, Copy, Debug, PartialEq)] enum Grab { + Axis(usize, usize), + Round(usize), + Feather(usize), + Turn(usize, i8), + Focus(usize), + Transition(usize), Ring(usize), Pin(usize), New, } +/// Distance in screen points from `at` to the infinite line through screen points `a` and `b`. +fn line_distance(at: Pos2, a: Pos2, b: Pos2) -> f32 { + let d = b - a; + let len = d.length(); + if len <= f32::EPSILON { at.distance(a) } else { ((at - a).x * d.y - (at - a).y * d.x).abs() / len } +} + fn grab(fields: &Map, xf: &ViewXform, at: Pos2) -> Grab { let Some(b) = bounds(fields) else { return Grab::New }; + let k = kind(fields); let list = pins(fields); - let screen = |p: Pin| { - let d = to_doc(b, p); - xf.to_screen(d[0] as f32, d[1] as f32) - }; + let screen = |d: [f64; 2]| xf.to_screen(d[0] as f32, d[1] as f32); + let near = |d: [f64; 2]| screen(d).distance(at) <= GRAB; if let Some(i) = selected(fields) - && let Some(p) = list.get(i) - && (screen(*p).distance(at) - RING).abs() <= RING_BAND + && let Some(p) = list.get(i).copied() { - return Grab::Ring(i); + if let Some(s) = p.iris { + if let Some(a) = axis_handles().iter().position(|&(u, v)| near(iris_point(b, p, s, u, v))) { + return Grab::Axis(i, a); + } + if k == Some(Kind::Iris) { + let (u, v) = round_handle(s); + if near(iris_point(b, p, s, u, v)) { + return Grab::Round(i); + } + if feather_handles(s).iter().any(|&(u, v)| near(iris_point(b, p, s, u, v))) { + return Grab::Feather(i); + } + } + } + if let Some(t) = p.tilt { + for side in [1i8, -1] { + if near(tilt_turn_handle(b, p, t, xf, f64::from(side))) { + return Grab::Turn(i, side); + } + } + } + if (screen(to_doc(b, p)).distance(at) - RING).abs() <= RING_BAND { + return Grab::Ring(i); + } + if let Some(t) = p.tilt + && screen(to_doc(b, p)).distance(at) > GRAB + { + let ss = short_side(b); + let on = |offset: f64| line_distance(at, screen(tilt_point(b, p, t, offset, -ss)), screen(tilt_point(b, p, t, offset, ss))) <= LINE_GRAB; + let focus = t.focus * ss; + if on(focus) || on(-focus) { + return Grab::Focus(i); + } + let edge = (t.focus + t.transition) * ss; + if on(edge) || on(-edge) { + return Grab::Transition(i); + } + } } list.iter() .enumerate() - .map(|(i, p)| (i, screen(*p).distance(at))) + .map(|(i, p)| (i, screen(to_doc(b, *p)).distance(at))) .filter(|(_, d)| *d <= GRAB) .min_by(|a, b| a.1.total_cmp(&b.1)) .map_or(Grab::New, |(i, _)| Grab::Pin(i)) @@ -201,10 +534,14 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields)) else { return false }; let f = &mut d.fields; let Some(b) = bounds(f) else { return false }; + let Some(k) = kind(f) else { return false }; let (start, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); ctx.set_cursor_icon(match grab(f, xf, over) { - Grab::Ring(_) => egui::CursorIcon::Alias, + Grab::Ring(_) | Grab::Round(_) | Grab::Turn(..) => egui::CursorIcon::Alias, + Grab::Axis(..) | Grab::Feather(_) => egui::CursorIcon::Move, + Grab::Focus(_) | Grab::Transition(_) => egui::CursorIcon::ResizeVertical, Grab::Pin(_) => egui::CursorIcon::Grab, + Grab::New if k == Kind::Tilt => egui::CursorIcon::Default, Grab::New => egui::CursorIcon::Crosshair, }); if !down && press.is_none() { @@ -216,6 +553,12 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let mut list = pins(f); if start { let drag = match grab(f, xf, at) { + Grab::Axis(i, a) => json!({"axis": i, "k": a}), + Grab::Round(i) => json!({"round": i}), + Grab::Feather(i) => json!({"feather": i}), + Grab::Turn(i, side) => json!({"turn": i, "side": side}), + Grab::Focus(i) => json!({"focus": i}), + Grab::Transition(i) => json!({"transition": i}), Grab::Ring(i) => { let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); json!({"ring": i, "angle": (doc[1] - c[1]).atan2(doc[0] - c[0])}) @@ -225,11 +568,14 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); json!({"pin": i, "dx": c[0] - doc[0], "dy": c[1] - doc[1]}) } - Grab::New if list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { - // A new pin takes the selected pin's blur, as the dialog's Blur shows it. - let blur = f.get("blur").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + // Tilt-Shift has one band: a click elsewhere adds nothing. + Grab::New if k != Kind::Tilt && list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { + // A new pin takes the selected pin's amount (as the dialog shows it) and shape. + let blur = f.get(k.amount_key()).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, k.max()); + let fallback = if k == Kind::Spin { Iris::spin() } else { Iris::default() }; + let iris = matches!(k, Kind::Iris | Kind::Spin).then(|| selected(f).and_then(|i| list.get(i)).and_then(|p| p.iris).unwrap_or(fallback)); let (x, y) = from_doc(b, doc); - list.push(Pin { x, y, blur }); + list.push(Pin { x, y, blur, iris, tilt: None }); set_pins(f, &list); select(f, Some(list.len() - 1)); json!({"pin": list.len() - 1, "dx": 0.0, "dy": 0.0}) @@ -241,63 +587,180 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over return true; } let Some(drag) = f.get("__drag").cloned() else { return true }; - if let Some(i) = drag.get("pin").and_then(Value::as_u64).map(|i| i as usize) + let index = |key: &str| drag.get(key).and_then(Value::as_u64).map(|i| i as usize); + let ss = short_side(b); + if let Some(i) = index("pin") && let Some(p) = list.get_mut(i) { - let num = |k: &str| drag.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let num = |key: &str| drag.get(key).and_then(Value::as_f64).unwrap_or(0.0); (p.x, p.y) = from_doc(b, [doc[0] + num("dx"), doc[1] + num("dy")]); - set_pins(f, &list); - } else if let Some(i) = drag.get("ring").and_then(Value::as_u64).map(|i| i as usize) + } else if let Some(i) = index("ring") && let Some(p) = list.get_mut(i) { - // The angle turned since the last frame, the short way round: clockwise raises the blur. + // The angle turned since the last frame, the short way round: clockwise raises the amount. let c = to_doc(b, *p); let a = (doc[1] - c[1]).atan2(doc[0] - c[0]); let last = drag.get("angle").and_then(Value::as_f64).unwrap_or(a); let turn = (a - last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; - p.blur = (p.blur + turn / std::f64::consts::TAU * MAX_BLUR).clamp(0.0, MAX_BLUR); - set_pins(f, &list); - select(f, Some(i)); + p.blur = (p.blur + turn / std::f64::consts::TAU * k.max()).clamp(0.0, k.max()); f.insert("__drag".into(), json!({"ring": i, "angle": a})); + } else if let Some(i) = index("axis") + && let Some(p) = list.get_mut(i) + && let Some(mut s) = p.iris + { + // The handle follows the pointer: its distance is that axis's radius, its direction turns + // the shape (the +v handle sits a quarter turn after +u). + let c = to_doc(b, *p); + let (dx, dy) = (doc[0] - c[0], doc[1] - c[1]); + let r = (dx.hypot(dy) / ss).clamp(MIN_RADIUS, 1.0); + let a = index("k").unwrap_or(0); + if a % 2 == 0 { + s.rx = r; + } else { + s.ry = r; + } + s.angle = dy.atan2(dx).to_degrees() - 90.0 * a as f64; + p.iris = Some(s.clean()); + } else if let Some(i) = index("round") + && let Some(p) = list.get_mut(i) + && let Some(s) = p.iris + { + // Out along the diagonal rounds towards a rectangle: the handle is the shape's diagonal + // point (t, t), t = 2^(−1/m), so t gives the exponent back. + let (u, v) = iris_local(b, *p, s, doc); + let t = ((u.abs() + v.abs()) / 2.0).clamp(0.5f64.sqrt(), 0.5f64.powf(1.0 / 8.0)); + let m = -std::f64::consts::LN_2 / t.ln(); + p.iris = Some(Iris { roundness: (m - 2.0) / 6.0 * 100.0, ..s }.clean()); + } else if let Some(i) = index("feather") + && let Some(p) = list.get_mut(i) + && let Some(s) = p.iris + { + // The dots sit where the blur starts: the pointer's distance in the shape's own measure. + let (u, v) = iris_local(b, *p, s, doc); + let m = s.exponent(); + let e = (u.abs().powf(m) + v.abs().powf(m)).powf(1.0 / m); + p.iris = Some(Iris { feather: if e.is_finite() { e } else { s.feather }, ..s }.clean()); + } else if let Some(i) = index("turn") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + // The dot's side of the band points from the centre towards the pointer: the engine's + // normal is (sin a, cos a). + let c = to_doc(b, *p); + let side = drag.get("side").and_then(Value::as_f64).unwrap_or(1.0).signum(); + let (nx, ny) = ((doc[0] - c[0]) * side, (doc[1] - c[1]) * side); + if nx.hypot(ny) > 1e-9 { + p.tilt = Some(Tilt { angle: nx.atan2(ny).to_degrees(), ..t }.clean()); + } + } else if let Some(i) = index("focus") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + p.tilt = Some(Tilt { focus: tilt_offset(b, *p, t, doc).abs() / ss, ..t }.clean()); + } else if let Some(i) = index("transition") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + p.tilt = Some(Tilt { transition: tilt_offset(b, *p, t, doc).abs() / ss - t.focus, ..t }.clean()); + } + set_pins(f, &list); + if let Some(i) = ["pin", "ring", "axis", "round", "feather", "turn", "focus", "transition"].into_iter().find_map(index) { + select(f, Some(i)); } ctx.request_repaint(); true } -/// Draw the pins over the image while the workspace is open: a dot per pin (filled when -/// selected) and, round the selected one, its ring with the blur as an arc from the top. -pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { - let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; - let Some(b) = bounds(&d.fields) else { return }; - let sel = selected(&d.fields); +/// Draw a dark-edged white line from `a` to `b`, dashed when `dash` is set. +fn draw_line(painter: &egui::Painter, a: Pos2, b: Pos2, dash: bool) { let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); - for (i, p) in pins(&d.fields).into_iter().enumerate() { - let c = to_doc(b, p); - let c = xf.to_screen(c[0] as f32, c[1] as f32); - if sel == Some(i) { - painter.circle_stroke(c, RING, dark); - painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); - let frac = (p.blur / MAX_BLUR) as f32; - let steps = (frac * 64.0).ceil().max(1.0) as usize; - let arc: Vec = (0..=steps) - .map(|k| { - let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * k as f32 / steps as f32; - c + vec2(a.cos(), a.sin()) * RING - }) - .collect(); - if frac > 0.0 { - painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); - painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); - } - painter.circle(c, DOT, Color32::WHITE, Stroke::new(1.0, Color32::from_black_alpha(180))); - } else { - painter.circle_stroke(c, DOT, dark); - painter.circle_stroke(c, DOT, light); - } + if dash { + painter.extend(egui::Shape::dashed_line(&[a, b], dark, 6.0, 5.0)); + painter.extend(egui::Shape::dashed_line(&[a, b], light, 6.0, 5.0)); + } else { + painter.line_segment([a, b], dark); + painter.line_segment([a, b], light); } } -/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them. +/// Draw the pins over the image while the workspace is open: a dot per pin (filled when +/// selected) and, round the selected one, its ring with the amount as an arc from the top, and +/// its outline and handles or Tilt-Shift's lines. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; + let Some(b) = bounds(&d.fields) else { return }; + let Some(k) = kind(&d.fields) else { return }; + let sel = selected(&d.fields); + let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); + let screen = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let handle_stroke = Stroke::new(1.0, Color32::from_black_alpha(180)); + for (i, p) in pins(&d.fields).into_iter().enumerate() { + let c = screen(to_doc(b, p)); + if sel != Some(i) { + painter.circle_stroke(c, DOT, dark); + painter.circle_stroke(c, DOT, light); + continue; + } + if let Some(s) = p.iris { + // The outline: where the blur reaches its full amount (Iris) or where the spin ends. + let m = s.exponent(); + let outline: Vec = (0..=128) + .map(|n| { + let a = std::f64::consts::TAU * f64::from(n) / 128.0; + let (cos, sin) = (a.cos(), a.sin()); + let r = (cos.abs().powf(m) + sin.abs().powf(m)).powf(-1.0 / m); + screen(iris_point(b, p, s, r * cos, r * sin)) + }) + .collect(); + painter.add(egui::Shape::line(outline.clone(), dark)); + painter.add(egui::Shape::line(outline, light)); + for (u, v) in axis_handles() { + painter.circle(screen(iris_point(b, p, s, u, v)), HANDLE, Color32::WHITE, handle_stroke); + } + if k == Kind::Iris { + let (u, v) = round_handle(s); + let r = egui::Rect::from_center_size(screen(iris_point(b, p, s, u, v)), vec2(HANDLE * 2.0, HANDLE * 2.0)); + painter.rect(r, 1.0, Color32::WHITE, handle_stroke, egui::StrokeKind::Middle); + for (u, v) in feather_handles(s) { + painter.circle(screen(iris_point(b, p, s, u, v)), HANDLE + 1.0, Color32::from_white_alpha(230), handle_stroke); + } + } + } + if let Some(t) = p.tilt { + // The sharp band's edges (solid) and where the blur is full (dashed), across the image. + let ss = short_side(b); + let clip = painter.with_clip_rect(egui::Rect::from_two_pos(screen([b[0], b[1]]), screen([b[2], b[3]])).intersect(painter.clip_rect())); + let reach = (b[2] - b[0]).hypot(b[3] - b[1]); + for (offset, dash) in [(t.focus, false), (t.focus + t.transition, true)] { + for side in [1.0, -1.0] { + let o = side * offset * ss; + draw_line(&clip, screen(tilt_point(b, p, t, o, -reach)), screen(tilt_point(b, p, t, o, reach)), dash); + } + } + for side in [1.0, -1.0] { + painter.circle(screen(tilt_turn_handle(b, p, t, xf, side)), HANDLE + 0.5, Color32::WHITE, handle_stroke); + } + } + painter.circle_stroke(c, RING, dark); + painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); + let frac = (p.blur / k.max()) as f32; + let steps = (frac * 64.0).ceil().max(1.0) as usize; + let arc: Vec = (0..=steps) + .map(|n| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * n as f32 / steps as f32; + c + vec2(a.cos(), a.sin()) * RING + }) + .collect(); + if frac > 0.0 { + painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); + painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); + } + painter.circle(c, DOT, Color32::WHITE, handle_stroke); + } +} + +/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them, as the +/// top-level pixel params (Tilt-Shift's `blur`) already do. Spin's blur angle doesn't. pub fn scale_for_proxy(params: &mut Value, k: u32) { if k <= 1 { return; diff --git a/crates/ui-egui/src/blur_pins_tests.rs b/crates/ui-egui/src/blur_pins_tests.rs index 33a691d3d8..0b84a6dbff 100644 --- a/crates/ui-egui/src/blur_pins_tests.rs +++ b/crates/ui-egui/src/blur_pins_tests.rs @@ -6,11 +6,16 @@ use egui_kittest::Harness; use serde_json::json; use crate::PhotocraftApp; -use crate::blur_pins::{COMMAND, Pin, pins, selected}; +use crate::blur_pins::{COMMAND, IRIS, Iris, Pin, SPIN, TILT, pins, selected}; use crate::canvas::ViewXform; /// A 400×300 document at 400 %, so it fills the window round the dialog, with Field Blur open. fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { + harness_for(COMMAND, selection) +} + +/// [`harness`] with the Blur Gallery `command` open. +fn harness_for(command: &str, selection: bool) -> 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(); if selection { @@ -34,7 +39,7 @@ fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { h.run_steps(4); let v = &mut h.state_mut().ui.views[0]; (v.zoom, v.center, v.fit_pending) = (4.0, [200.0, 150.0], false); - crate::filter_dialog::open(h.state_mut(), COMMAND).unwrap(); + crate::filter_dialog::open(h.state_mut(), command).unwrap(); h.run_steps(4); h } @@ -86,7 +91,7 @@ fn on_screen(h: &Harness<'static, PhotocraftApp>, p: Pin, bounds: [f64; 4]) -> P fn it_opens_with_one_selected_pin_in_the_middle_of_the_bounds() { let h = harness(false); let f = fields(&h); - assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0, iris: None, tilt: None }]); assert_eq!(selected(&f), Some(0)); assert_eq!(f["__bounds"], json!([0, 0, 400, 300])); assert!(!f.contains_key("centerX") && !f.contains_key("centerY"), "the pins replace the single centre"); @@ -173,8 +178,210 @@ fn proxy_previews_scale_the_pins_blur() { assert_eq!(p["pins"][0]["blur"], json!(10.0)); } +/// Screen point of an Iris pin's local point `(u, v)` (1 = on the outline along that axis), on the +/// 400×300 canvas (shorter side 300), as the engine places it. +fn iris_at(h: &Harness<'static, PhotocraftApp>, p: Pin, u: f64, v: f64) -> Pos2 { + let s = p.iris.unwrap(); + let (cx, cy) = (p.x * 400.0, p.y * 300.0); + let (x, y) = (u * s.rx * 300.0, v * s.ry * 300.0); + let (sin, cos) = s.angle.to_radians().sin_cos(); + xf(h).to_screen((cx + x * cos - y * sin) as f32, (cy + x * sin + y * cos) as f32) +} + +fn only_pin(h: &Harness<'static, PhotocraftApp>) -> Pin { + pins(&fields(h))[0] +} + +#[test] +fn iris_opens_with_its_shape_as_a_pin() { + let h = harness_for(IRIS, false); + let f = fields(&h); + let list = pins(&f); + assert_eq!(list.len(), 1); + let (p, s, d) = (list[0], list[0].iris.unwrap(), Iris::default()); + assert_eq!((p.x, p.y, p.blur), (0.5, 0.5, 15.0)); + // The dialog's defaults come from the command spec (f32), so compare loosely. + for (got, want) in [(s.rx, d.rx), (s.ry, d.ry), (s.angle, d.angle), (s.roundness, d.roundness), (s.feather, d.feather)] { + assert!((got - want).abs() < 1e-6, "{s:?}"); + } + for k in ["centerX", "radiusX", "radiusY", "angle", "roundness", "feather"] { + assert!(!f.contains_key(k), "{k} is in the pin now"); + } + assert!(f["pins"][0]["radiusX"].as_f64().is_some_and(|r| (r - 0.35).abs() < 1e-6), "the command's own keys: {}", f["pins"]); +} + +/// The selected Iris pin's handles: an outline handle resizes its axis and turns the shape, the +/// square rounds it, the inner dots set where the blur starts; a new pin copies the shape; OK +/// applies them all. +#[test] +fn iris_handles_resize_turn_round_and_feather() { + let mut h = harness_for(IRIS, false); + // A small shape, so its handles sit in the free middle of the window, left of the dialog. + let small = json!([{"x": 0.5, "y": 0.5, "blur": 15.0, "radiusX": 0.1, "radiusY": 0.08, "angle": 0.0, "roundness": 0.0, "feather": 0.5}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), small); + h.run_steps(2); + // Drag the +u handle (30 px right of the centre) to 40 px below it: radius 40/300, a quarter turn. + let p = only_pin(&h); + let below = xf(&h).to_screen(200.0, 190.0); + let from = iris_at(&h, p, 1.0, 0.0); + drag(&mut h, &[from, from.lerp(below, 0.5), below]); + let s = only_pin(&h).iris.unwrap(); + assert!((s.rx - 40.0 / 300.0).abs() < 2e-3 && (s.angle - 90.0).abs() < 0.5, "{s:?}"); + assert_eq!(s.ry, 0.08, "the other axis keeps its radius"); + // Drag the corner square out along the diagonal to 0.9: the exponent 6.58, roundness 76. + let p = only_pin(&h); + let t = 0.5f64.sqrt(); + let path = [iris_at(&h, p, t, -t), iris_at(&h, p, 0.8, -0.8), iris_at(&h, p, 0.9, -0.9)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + let m = -std::f64::consts::LN_2 / 0.9f64.ln(); + assert!((s.roundness - (m - 2.0) / 6.0 * 100.0).abs() < 1.0, "{s:?}"); + // Drag a feather dot in to a fifth of the way along the diagonal. + let p = only_pin(&h); + let k = 0.5 * 0.9; + let path = [iris_at(&h, p, k, k), iris_at(&h, p, 0.3, 0.3), iris_at(&h, p, 0.2, 0.2)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + let want = 0.2 * 2f64.powf(1.0 / m); + assert!((s.feather - want).abs() < 0.01, "{} vs {want}", s.feather); + // A click away from the shape adds a pin with the same shape and blur. + let r = h.state().last_canvas_rect; + let at = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, at); + button(&mut h, at, true); + button(&mut h, at, false); + h.run_steps(2); + let list = pins(&fields(&h)); + assert_eq!(list.len(), 2); + assert_eq!(list[1].iris, list[0].iris); + assert_eq!(selected(&fields(&h)), Some(1)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, IRIS); + assert_eq!(params["pins"].as_array().map(Vec::len), Some(2)); + assert_eq!(params["pins"][0]["angle"], json!(s.angle), "{params}"); +} + +/// Hostile pin values from automation are cleaned before anything uses them. +#[test] +fn iris_pins_are_cleaned() { + let mut h = harness_for(IRIS, false); + let bad = json!([{"x": 7, "y": -3, "blur": -1, "radiusX": 0, "radiusY": 1e9, "angle": 540.0, "roundness": 300, "feather": 2}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), bad); + let p = only_pin(&h); + assert_eq!((p.x, p.y, p.blur), (1.0, 0.0, 0.0)); + let s = p.iris.unwrap(); + assert_eq!((s.rx, s.ry, s.angle, s.roundness, s.feather), (0.01, 1.0, -180.0, 100.0, 0.99)); + h.run_steps(2); +} + +fn at_doc(h: &Harness<'static, PhotocraftApp>, x: f64, y: f64) -> Pos2 { + xf(h).to_screen(x as f32, y as f32) +} + +/// Tilt-Shift's one band on the 400×300 canvas (shorter side 300): the solid lines set the sharp +/// band, the dashed ones the transition, the dots turn it, the pin moves it; a click adds nothing; +/// OK sends the command's own params. +#[test] +fn tilt_shift_lines_and_dots_shape_the_band() { + let mut h = harness_for(TILT, false); + let f = fields(&h); + assert!(!f.contains_key("pins"), "Tilt-Shift keeps its own params"); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.focus - 0.1).abs() < 1e-6 && (t.transition - 0.15).abs() < 1e-6 && t.angle == 0.0, "{t:?}"); + // The band is horizontal: the solid lines at y = 150 ± 30, the dashed ones 45 px further. + // Drag the upper dashed line, far left of the pin, out to 80 px from the centre: the transition + // is 50 px. + let path = [at_doc(&h, 60.0, 75.0), at_doc(&h, 60.0, 72.0), at_doc(&h, 60.0, 70.0)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.transition - 50.0 / 300.0).abs() < 1e-3, "{t:?}"); + // Drag the lower solid line 20 px down: the sharp band is 50 px each side. + let path = [at_doc(&h, 60.0, 180.0), at_doc(&h, 60.0, 190.0), at_doc(&h, 60.0, 200.0)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.focus - 50.0 / 300.0).abs() < 1e-3 && (t.transition - 50.0 / 300.0).abs() < 1e-3, "{t:?}"); + // Turn: pull the lower line's dot round to 30° (the band's normal is (sin a, cos a)). + let p = only_pin(&h); + let dot = crate::blur_pins::tilt_turn_handle([0.0, 0.0, 400.0, 300.0], p, t, &xf(&h), 1.0); + let (s30, c30) = 30f64.to_radians().sin_cos(); + let path = [at_doc(&h, dot[0], dot[1]), at_doc(&h, 200.0 + 40.0 * s30, 150.0 + 40.0), at_doc(&h, 200.0 + 60.0 * s30, 150.0 + 60.0 * c30)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.angle - 30.0).abs() < 0.5, "{t:?}"); + assert!((t.focus - 50.0 / 300.0).abs() < 1e-3, "turning keeps the widths"); + // The pin moves the band. + let path = [at_doc(&h, 200.0, 150.0), at_doc(&h, 210.0, 145.0), at_doc(&h, 220.0, 140.0)]; + drag(&mut h, &path); + let p = only_pin(&h); + assert!((p.x - 0.55).abs() < 1e-3 && (p.y - 140.0 / 300.0).abs() < 1e-3, "{p:?}"); + // A click away from the band's lines adds nothing: there is one band. + let r = h.state().last_canvas_rect; + let away = pos2(r.left() + 30.0, r.top() + 30.0); + move_to(&mut h, away); + button(&mut h, away, true); + button(&mut h, away, false); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + h.key_press(egui::Key::Delete); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1, "Delete keeps the one band"); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, TILT); + for (k, want) in [("centerX", 0.55), ("angle", t.angle), ("focus", t.focus), ("transition", t.transition), ("blur", 15.0)] { + assert!(params[k].as_f64().is_some_and(|v| (v - want).abs() < 1e-3), "{k}: {params}"); + } + assert!(params.get("pins").is_none(), "{params}"); +} + +/// Spin Blur: outline handles resize and turn the ellipse, the ring is the blur angle (a quarter +/// turn = 90°), a click adds a pin with the same shape, OK sends `blurAngle` per pin. +#[test] +fn spin_blur_handles_and_its_angle_ring() { + let mut h = harness_for(SPIN, false); + let p = only_pin(&h); + assert_eq!(p.blur, 15.0, "the blur angle"); + assert!(p.iris.is_some_and(|s| (s.rx - 0.3).abs() < 1e-6 && (s.ry - 0.3).abs() < 1e-6)); + let small = json!([{"x": 0.5, "y": 0.5, "blurAngle": 15.0, "radiusX": 0.1, "radiusY": 0.1, "angle": 0.0}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), small); + h.run_steps(2); + // The +v handle (30 px below the centre) dragged 45 px out to the right: radius 45/300, and + // the shape turned so +v points right (−90°). + let p = only_pin(&h); + let path = [iris_at(&h, p, 0.0, 1.0), at_doc(&h, 230.0, 175.0), at_doc(&h, 245.0, 150.0)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + assert!((s.ry - 45.0 / 300.0).abs() < 2e-3 && (s.angle + 90.0).abs() < 0.5 && s.rx == 0.1, "{s:?}"); + // A quarter turn clockwise on the ring adds 90° of blur angle. + let c = at_doc(&h, 200.0, 150.0); + let ring: Vec = (0..=6).map(|k| c + vec2(24.0 * (k as f32 * 0.2618).cos(), 24.0 * (k as f32 * 0.2618).sin())).collect(); + drag(&mut h, &ring); + let p = only_pin(&h); + assert!((p.blur - 105.0).abs() < 1.0, "{}", p.blur); + assert_eq!(fields(&h)["blurAngle"], json!(p.blur), "the dialog's Blur Angle follows the pin"); + let r = h.state().last_canvas_rect; + let at = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, at); + button(&mut h, at, true); + button(&mut h, at, false); + h.run_steps(2); + let list = pins(&fields(&h)); + assert_eq!(list.len(), 2); + assert_eq!((list[1].iris, list[1].blur), (list[0].iris, list[0].blur)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, SPIN); + assert!(params["pins"][0]["blurAngle"].as_f64().is_some_and(|a| (a - 105.0).abs() < 1.0), "{params}"); + assert!(params["pins"][0].get("blur").is_none() && params["pins"][0].get("roundness").is_none(), "{params}"); +} + /// Alt+Cancel → Reset (#3871) starts the workspace again from one pin in the middle, as when it -/// opened, instead of leaving it with no pins; the bounds captured on opening stay. +/// opened, instead of leaving it with no pins; the bounds captured on opening stay. An Iris pin +/// gets the command's default shape back, and Spin Blur and Tilt-Shift their defaults too. #[test] fn alt_reset_starts_again_from_one_pin() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -186,8 +393,35 @@ fn alt_reset_starts_again_from_one_pin() { fields.insert("__selected".into(), json!(1)); assert!(crate::dialog_alt_reset::supported(&fields)); crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); - assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0, iris: None, tilt: None }]); assert_eq!(selected(&fields), Some(0)); assert_eq!(fields["__bounds"], bounds); assert!(!fields.contains_key("centerX") && !fields.contains_key("centerY"), "{fields:?}"); + let id = crate::filter_dialog::open(&mut app, IRIS).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let bent = json!([{"x": 0.2, "y": 0.2, "blur": 40.0, "radiusX": 0.1, "radiusY": 0.1, "angle": 30.0, "roundness": 50.0, "feather": 0.2}]); + fields.insert("pins".into(), bent); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let list = pins(&fields); + assert_eq!(list.len(), 1); + assert_eq!((list[0].x, list[0].y, list[0].blur), (0.5, 0.5, 15.0)); + // The dialog's defaults come from the command spec (f32), so compare loosely. + let (s, d) = (list[0].iris.unwrap(), Iris::default()); + for (got, want) in [(s.rx, d.rx), (s.ry, d.ry), (s.angle, d.angle), (s.roundness, d.roundness), (s.feather, d.feather)] { + assert!((got - want).abs() < 1e-6, "{s:?}"); + } + let id = crate::filter_dialog::open(&mut app, SPIN).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("pins".into(), json!([{"x": 0.2, "y": 0.2, "blurAngle": 90.0}, {"x": 0.7, "y": 0.6, "blurAngle": 30.0}])); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let list = pins(&fields); + assert_eq!(list.len(), 1); + assert_eq!((list[0].x, list[0].y, list[0].blur), (0.5, 0.5, 15.0)); + let id = crate::filter_dialog::open(&mut app, TILT).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("centerX".into(), json!(0.2)); + fields.insert("angle".into(), json!(30.0)); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let p = pins(&fields)[0]; + assert_eq!((p.x, p.y, p.tilt.map(|t| t.angle)), (0.5, 0.5, Some(0.0))); } diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 515e6e5a21..332c80c4b7 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -249,7 +249,6 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.set_width(150.0); ui.spacing_mut().item_spacing.y = 1.0; let (br_rect, br_resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click()); - br_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brushes"))); if br_resp.hovered() { ui.painter().rect_filled(br_rect, t.radius_sm, t.hover); } diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..670f442be2 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -24,7 +24,7 @@ fn sections_with_boxes_have_flags() { /// What each section's controls edit, set to non-default values. fn edit_section(i: usize, b: &mut BrushSettings) { - let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum }; + let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum, ..Default::default() }; match i { 0 => { b.size = 87.0; @@ -399,6 +399,21 @@ fn brush_mode_dropdown_shows_outside_the_pro_themes() { assert_eq!(j[n].0, "tools.setBrush"); } +#[test] +fn brush_and_eraser_size_fields_keep_sizes_above_2500_px() { + // #5708: outside the Pro themes the Size field capped at 2500 px and clamped a bigger brush + // (set from the picker or `]`) on the next frame; Photoshop and the engine allow 5000 px. + for tool in [crate::state::Tool::Brush, crate::state::Tool::Eraser] { + let mut h = options_bar_harness(tool); + let n = h.state().session.journal.len(); + h.state_mut().session.tools.brush.size = 4000.0; + h.run_steps(4); + assert_eq!(h.state().session.tools.brush.size, 4000.0, "{tool:?}"); + let j = &h.state().session.journal; + assert_eq!(j.len(), n, "showing the bar issues nothing: {:?}", &j[n..]); + } +} + #[test] fn airbrush_smoothing_options_and_symmetry_buttons_work() { // #1177: in the default (Photoshop) theme these options-bar buttons did nothing. @@ -1268,6 +1283,27 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(!h.state().ui.views[0].fit_pending); } +#[test] +fn a_dynamic_gets_its_own_response_curve() { + use egui_kittest::kittest::Queryable; + let mut h = harness(0, 1); + h.state_mut().run("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure"}}})).unwrap(); + h.run_steps(3); + // Only Size has a control with an input to shape; its curve button shows the editor. + assert_eq!(h.query_all_by_label("Response curve").count(), 1); + h.get_by_label("Response curve").click(); + h.run_steps(3); + h.get_by_label("Soft").click(); + h.run_steps(3); + let (id, p) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + let curve: Vec<[f32; 2]> = serde_json::from_value(p["brush"]["shapeDynamics"]["size"]["curve"].clone()).unwrap(); + assert_eq!(curve, vec![[0.0, 0.0], [0.3, 0.6], [1.0, 1.0]]); + let b = &h.state().session.tools.brush; + assert_eq!(b.shape_dynamics.size.curve.points(), curve); + assert!(b.shape_dynamics.roundness.curve.is_linear() && b.transfer.opacity.curve.is_linear()); +} + #[test] fn brush_settings_hamburger_menu_actions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 8ac5bb8c2c..9bef83a647 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -166,7 +166,7 @@ pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, picks: Vec) { crate::brush_preset_dialog::open(app, None); } Pick::Import => { - let _ = app.open_dialog_file(); + let _ = crate::preset_files_ui::import_brushes(app); } } } diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..e7d3a16f56 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -67,9 +67,23 @@ fn dim(ui: &mut egui::Ui, s: &str) { ui.label(RichText::new(s).color(t.text_dim)); } -/// `Control: [Off ▾] [25]`: the controller pop-up plus Fade's step count. `angle` offers the +/// Whether a dynamic's control has a 0..1 input its own response curve shapes (not Off, not the +/// direction controls, not Angle Jitter, whose controls set an angle). +pub(crate) fn takes_curve(control: Control, angle: bool) -> bool { + !angle && matches!(control, Control::Fade | Control::PenPressure | Control::PenTilt | Control::StylusWheel | Control::Rotation) +} + +/// egui memory key of the open/closed state of a dynamic's curve editor. +pub(crate) fn curve_open_id(id: &str) -> egui::Id { + egui::Id::new(("brush-curve-open", id)) +} + +/// `Control: [Off ▾] [25] [~]`: the controller pop-up, Fade's step count and the button that +/// shows this dynamic's own response curve (lit while the curve isn't linear). `angle` offers the /// direction controls (Photoshop only has them for Angle Jitter). fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { + let open_id = curve_open_id(id); + let mut open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(false); ui.horizontal(|ui| { dim(ui, tl!("Control:")); let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 }; @@ -80,7 +94,17 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32; } } + if takes_curve(d.control, angle) && crate::icons::button(ui, "spline", 22.0, open || !d.curve.is_linear(), tl!("Response curve")).clicked() { + open = !open; + } }); + if open && takes_curve(d.control, angle) { + let mut points = d.curve.points(); + if crate::pressure_curve_ui::edit_points(ui, egui::Id::new(("brush-curve-rect", id)), &mut points) { + d.curve = paint::ResponseCurve::new(&points); + } + } + ui.data_mut(|m| m.insert_temp(open_id, open)); ui.add_space(2.0); } diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 703b6e0290..709c91c151 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -17,7 +17,7 @@ use egui::{Color32, RichText, Sense, Stroke, pos2, vec2}; use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE}; use serde_json::json; -use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, run_or_status}; +use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, UNGROUPED, commit_gesture, full_uv, grouped_presets, run_or_status}; use crate::brush_preview; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; @@ -98,6 +98,11 @@ pub struct ListLayout { /// The Brushes tab's list. pub const PANEL_LIST: ListLayout = ListLayout { id: "brush-presets", max_height: 400.0, cell: 52.0, indent: 20.0, cards: false }; +fn responsive_list_layout(ui: &egui::Ui) -> ListLayout { + let height = ui.available_height().clamp(180.0, 640.0); + ListLayout { max_height: height, ..PANEL_LIST } +} + /// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as /// [`UNGROUPED`]. pub fn group_key(presets: &[BrushPreset], label: &str) -> String { @@ -920,7 +925,28 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& egui::Area::new(egui::Id::new(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name)); + if matches!(&*d, BrushDrag::Preset(_)) { + ui.set_opacity(0.8); + } + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if let BrushDrag::Preset(n) = &*d + && let Some(preset) = presets.iter().find(|preset| preset.name == *n) + { + let brush = &preset.brush; + let tip = brush_preview::tip_texture( + ui.ctx(), + &format!("brushes-drag-tip:{}", preset.name), + &brush.tip, + (brush.hardness, brush.angle, brush.roundness), + 36, + Tokens::get(ui.ctx()).text, + ); + ui.add(egui::Image::new(&tip).fit_to_exact_size(vec2(36.0, 36.0)).alt_text(&preset.name)); + } + ui.label(name); + }); + }); }, ); } @@ -930,13 +956,15 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& /// The Brushes tab. pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let inner_w = ui.available_width().max(180.0); // Size of the current brush (Photoshop's Brushes panel slider). let before = app.session.tools.brush.clone(); let mut b = before.clone(); ui.horizontal(|ui| { ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + let slider_w = (ui.available_width() - 90.0).clamp(100.0, 260.0); let mut lv = b.size.max(1.0).ln(); - ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| { + ui.add_sized(vec2(slider_w, 18.0), |ui: &mut egui::Ui| { let r = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); if r.changed() { b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); @@ -953,7 +981,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); - widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), WIDTH - 120.0, false); + let search_w = (inner_w - 112.0).clamp(90.0, inner_w - 40.0); + widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let view = &mut app.ui.brushes_panel.view; let grid_tip = "Grid view"; @@ -967,7 +996,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); - let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, PANEL_LIST); + let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, responsive_list_layout(ui)); apply(app, acts); ui.add_space(4.0); widgets::hairline(ui); @@ -988,8 +1017,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if crate::brush_picker::named(new, "Create new brush from the current settings").clicked() { crate::brush_preset_dialog::open(app, None); } - if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() { - let _ = app.open_dialog_file(); + if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr or image)").clicked() { + let _ = crate::preset_files_ui::import_brushes(app); } }); }); diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 4c61ecc54d..4672c062b4 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -7,6 +7,50 @@ use egui_kittest::kittest::{NodeT, Queryable}; use super::*; +#[test] +fn dragging_a_brush_shows_its_tip_preview() { + for theme in crate::theme::ThemeKind::ALL { + for mode in 0..3 { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.panels.brush_settings = mode != 2; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = if mode == 1 { BrushesView::Grid } else { BrushesView::List }; + if mode == 2 { + app.ui.brush_picker = Some([300.0, 120.0]); + } + let name = app.session.tools.presets[0].name.clone(); + let brush = app.session.tools.brush.clone(); + let presets = app.session.tools.presets.clone(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + app + }); + h.run_steps(4); + let start = h.get_by_role_and_label(Role::Button, &name).rect().center(); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let end = start + vec2(50.0, 10.0); + h.event(egui::Event::PointerMoved(end)); + h.run_steps(4); + assert!(matches!(egui::DragAndDrop::payload::(&h.ctx).as_deref(), Some(BrushDrag::Preset(n)) if n == &name)); + assert!(h.query_by_role_and_label(Role::Image, &name).is_some(), "dragged brush needs a real tip thumbnail, not only its name"); + let renders = brush_preview::render_count(&h.ctx); + h.run_steps(2); + assert_eq!(brush_preview::render_count(&h.ctx), renders, "unchanged drag preview stays cached"); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::Image, &name).is_none(), "preview disappears on release"); + assert_eq!(h.state().session.tools.brush, brush, "preview does not change brush settings"); + assert_eq!(h.state().session.tools.presets, presets, "dropping on the source preserves the library"); + } + } +} + #[test] fn grid_columns_centre_whole_columns_past_the_indent() { // 6 × 44 + 5 × 3 = 279 of the 290 past a 4 pt indent: 5 spare on each side (rounded down). diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index a2afa8d812..995645940e 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -212,11 +212,11 @@ pub(crate) fn cursor(app: &PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p Some(match h { Hit::Corner(i) => { let (a, b) = (q.get(i)?, q.get((i + 2) % 4)?); - crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) } Hit::Edge(i) => { let (a, b) = (mid(i)?, mid(i + 2)?); - crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) } Hit::Outside => { crate::type_transform::rotation_cursor(ctx, p); diff --git a/crates/ui-egui/src/camera_raw_preview_ui.rs b/crates/ui-egui/src/camera_raw_preview_ui.rs index f71959d5d5..e2d15f9e0c 100644 --- a/crates/ui-egui/src/camera_raw_preview_ui.rs +++ b/crates/ui-egui/src/camera_raw_preview_ui.rs @@ -110,11 +110,22 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: let rect = state.image_rect(viewport, size); // Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui // smooths it, even if Alt is released before the final part of the notch arrives. - let wheel = crate::wheel_nav::read(ui.ctx(), false, true); - if response.hovered() { + let cr_hovered = response.hovered(); + let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered); + if cr_hovered { ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); match wheel { Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size), + Some(crate::wheel_nav::Wheel::SnapZoom(n)) => { + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = state.scale(viewport, size); + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM); + } + if nz != state.scale(viewport, size) { + state.zoom_at(nz, at, viewport, size); + } + } Some(crate::wheel_nav::Wheel::Pan(delta)) => state.pan(delta, viewport, size), None => {} } @@ -287,6 +298,74 @@ mod tests { view.fit(); assert_eq!(view.image_rect(viewport, size), initial); } + #[test] + fn shift_alt_wheel_snaps_zoom_in_camera_raw() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = pos2(400.0, 300.0); + let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false }; + let scale_before = state.scale(viewport, size); + let n = 1i32; + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = state.scale(viewport, size); + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM); + } + assert_ne!(nz, scale_before, "zoom-in steps to a new level"); + state.zoom_at(nz, at, viewport, size); + assert_eq!(state.scale(viewport, size), nz, "zoom applied"); + assert!(nz > scale_before, "zoomed in"); + let scale_after_in = state.scale(viewport, size); + let mut nz = state.scale(viewport, size); + nz = crate::zoom_levels::step_in_range(nz, -1, MIN_ZOOM, MAX_ZOOM); + state.zoom_at(nz, at, viewport, size); + assert!(state.scale(viewport, size) < scale_after_in, "zoomed out"); + } + + #[test] + fn shift_alt_snap_at_limits_does_not_move() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = viewport.center(); + let state = CameraRawPreviewState { zoom: Some(MAX_ZOOM), center: [0.5, 0.5], hand: false }; + let nz = crate::zoom_levels::step_in_range(state.scale(viewport, size), 1, MIN_ZOOM, MAX_ZOOM); + assert_eq!(nz, state.scale(viewport, size), "at max, step up stays put"); + let state_lo = CameraRawPreviewState { zoom: Some(MIN_ZOOM), center: [0.5, 0.5], hand: false }; + let nz = crate::zoom_levels::step_in_range(state_lo.scale(viewport, size), -1, MIN_ZOOM, MAX_ZOOM); + assert_eq!(nz, state_lo.scale(viewport, size), "at min, step down stays put"); + let _ = at; + } + + #[test] + fn snap_zoom_from_fit_enters_explicit_zoom() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = viewport.center(); + let mut state = CameraRawPreviewState::default(); + assert!(state.zoom.is_none(), "starts in fit mode"); + let fit_scale = state.scale(viewport, size); + let nz = crate::zoom_levels::step_in_range(fit_scale, 1, MIN_ZOOM, MAX_ZOOM); + if nz != fit_scale { + state.zoom_at(nz, at, viewport, size); + } + assert!(state.zoom.is_some(), "snap zoom leaves fit mode"); + } + + #[test] + fn snap_zoom_preserves_point_under_pointer() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = pos2(300.0, 200.0); + let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false }; + let rect_before = state.image_rect(viewport, size); + let point_before = (at - rect_before.min) / rect_before.size(); + let nz = crate::zoom_levels::step_in_range(1.0, 1, MIN_ZOOM, MAX_ZOOM); + state.zoom_at(nz, at, viewport, size); + let rect_after = state.image_rect(viewport, size); + let point_after = (at - rect_after.min) / rect_after.size(); + assert!((point_before - point_after).length() < 0.01, "point preserved: {point_before:?} vs {point_after:?}"); + } + #[test] fn tiny_images_nonfinite_state_and_resize_stay_centered_and_finite() { let viewport = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index f3e2aa8fbb..d2f78a35c2 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -69,6 +69,11 @@ pub struct CameraRawDialog { /// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there, /// in linear light before this curve, and the preview undoes it to show the same. raw_curve: Option>, + /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). + undo: Vec, + redo: Vec, + /// Params at pointer-down. One drag is one undo step. + edit_origin: Option, } impl CameraRawDialog { @@ -638,6 +643,9 @@ fn open_pixels( render_ms: 0.0, raw_open: None, raw_curve: None, + undo: Vec::new(), + redo: Vec::new(), + edit_origin: None, }; d.render(ctx); app.camera_raw = Some(d); @@ -676,8 +684,10 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" => { + return Some(Err(format!("Camera Raw {key} must be a boolean"))); + } _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } @@ -754,14 +764,23 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti if d.params.point_curve != next_params.point_curve { d.curve_state = Default::default(); } - d.dirty |= d.params != next_params; - d.params = next_params; + if d.params != next_params { + remember_edit(d); + d.dirty = true; + d.params = next_params; + } if let Some(b) = ui.get("before").and_then(Value::as_bool) { d.show_before = b; } if let Some(update) = scope_update { update.apply(d, &mut app.ui.camera_raw_scope); } + if ui.get("undo").and_then(Value::as_bool) == Some(true) { + undo_edit(d); + } + if ui.get("redo").and_then(Value::as_bool) == Some(true) { + redo_edit(d); + } if d.dirty { d.render(ctx); } @@ -805,8 +824,11 @@ fn load_settings(app: &mut PhotocraftApp) -> Result { if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); } - d.dirty |= d.params != next; - d.params = next; + if d.params != next { + remember_edit(d); + d.dirty = true; + d.params = next; + } app.ui.status_error = false; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Loaded Camera Raw settings from {file}"), &[("file", &file)]) @@ -827,6 +849,49 @@ fn report_file_error(app: &mut PhotocraftApp, r: Result) { } } +const RAW_UNDO_LIMIT: usize = 40; + +fn remember_edit(d: &mut CameraRawDialog) { + if d.undo.len() == RAW_UNDO_LIMIT { + d.undo.remove(0); + } + d.undo.push(d.params.clone()); + d.redo.clear(); +} + +fn undo_edit(d: &mut CameraRawDialog) -> bool { + let Some(prev) = d.undo.pop() else { return false }; + d.redo.push(d.params.clone()); + if d.params.point_curve != prev.point_curve { + d.curve_state = Default::default(); + } + d.params = prev; + d.dirty = true; + d.edit_origin = None; + true +} + +fn redo_edit(d: &mut CameraRawDialog) -> bool { + let Some(next) = d.redo.pop() else { return false }; + d.undo.push(d.params.clone()); + if d.params.point_curve != next.point_curve { + d.curve_state = Default::default(); + } + d.params = next; + d.dirty = true; + d.edit_origin = None; + true +} + +fn history_keys(d: &mut CameraRawDialog, ctx: &egui::Context) { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + redo_edit(d); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + undo_edit(d); + } +} + fn commit(app: &mut PhotocraftApp) -> Result { let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; let result = match d.target { @@ -995,6 +1060,11 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let mut action: Option<&str> = None; egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.camera_raw.as_mut() else { return }; + history_keys(d, ctx); + let pointer = ctx.input(|i| i.pointer.primary_down()); + if pointer && d.edit_origin.is_none() { + d.edit_origin = Some(d.params.clone()); + } if let Some(display) = ¤t_display && display.key != d.display.key { @@ -1201,6 +1271,14 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { if fu.button(tl!("Load Settings…")).on_hover_text(tl!("Load settings from a Camera Raw .xmp file")).clicked() { action = Some("load"); } + if !ctx.input(|i| i.pointer.primary_down()) + && let Some(origin) = d.edit_origin.take() + && origin != d.params + { + let edited = std::mem::replace(&mut d.params, origin); + remember_edit(d); + d.params = edited; + } }); super::camera_raw_scope_ui::persist(app, ctx); if ctx.input(|i| i.key_pressed(egui::Key::Y)) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4b9a998852..93e059f9e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,7 +244,10 @@ pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let r = marquee_px(a, b); + let mut r = marquee_px(a, b); + if marquee_is_square(o, d) { + r = square_px(r, d.start, last); + } (r[2] > r[0] && r[3] > r[1]).then_some(r) } @@ -253,6 +256,37 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with +/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. +fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { + let (w, h) = (f64::from(o.marquee_width), f64::from(o.marquee_height)); + match o.marquee_style.as_str() { + "fixedSize" => false, + "fixedRatio" if w > 0.0 && h > 0.0 => w == h, + _ => d.marquee_mods().0, + } +} + +/// A constrained square `r` (`marquee_px` of its corners) with equal sides on whole pixels. +/// +/// The square is built in continuous coordinates, and snapping each axis to the pixel grid on +/// its own (floor the near edge, ceil the far one) made one side a pixel longer than the other +/// whenever the press point sat between pixels: a ⇧ drag read 101 × 102 (#5472). The shorter +/// side grows to the longer one, away from the press point (`start`, towards `end`), so the +/// square still covers every pixel the drag touched. +pub fn square_px(r: [f64; 4], start: [f64; 2], end: [f64; 2]) -> [f64; 4] { + let [x0, y0, x1, y1] = r; + let (w, h) = (x1 - x0, y1 - y0); + let n = w.max(h); + let grow = |lo: f64, hi: f64, extent: f64, from: f64, to: f64| { + let d = (n - extent).max(0.0); + if to < from { (lo - d, hi) } else { (lo, hi + d) } + }; + let (x0, x1) = grow(x0, x1, w, start[0], end[0]); + let (y0, y1) = grow(y0, y1, h, start[1], end[1]); + [x0, y0, x1, y1] +} + /// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the /// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other /// tools, an empty canvas or a point that is not a number. @@ -1720,15 +1754,29 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e #[cfg(not(target_arch = "wasm32"))] if app.background_jobs { app.filter_preview_worker.supersede(&request, crate::gpu_canvas::now_ms()); - if let Some((finished, computed)) = app.filter_preview_worker.poll() - && finished == request - { + if let Some((finished, computed)) = app.filter_preview_worker.poll() { match computed { - Ok(computed) => publish_filter_preview(app, idx, finished, computed.result, computed.ms), - Err(error) => { + // The dialog still holds these exact parameters. Publish as before, including an + // empty result: that is how a filter which no longer applies clears the canvas. + Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms), + // Mid-drag, a frame for the value the pointer has just left is what makes the + // canvas follow the drag. Three things have to hold for it: it belongs to this + // preview, it has a frame to show (an empty one would blank a good preview), and + // it must not displace a frame that is already exact for the dialog, which is + // what a slider dragged out and back would otherwise make the canvas do. + Ok(computed) + if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => + { + publish_filter_preview(app, idx, finished, computed.result, computed.ms); + } + Ok(_) => {} + // Errors stay on the exact request: a value the pointer has already dragged past + // must not raise a notice, and a still one will raise it when it is recomputed. + Err(error) if finished == request => { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: finished, result: None, committing: false }); crate::notices::error(app, error); } + Err(_) => {} } } let fresh = app.filter_preview.as_ref().is_some_and(|p| p.key == request); @@ -1763,15 +1811,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } let preview = app.filter_preview.as_ref()?; // Keep the last preview during a drag, but never one from another source or dialog. - // Completed worker results above must match every parameter before being uploaded. - if preview.key.doc != request.doc - || preview.key.revision != request.revision - || preview.key.dialog != request.dialog - || preview.key.active != request.active - || preview.key.target != request.target - || preview.key.k != request.k - || preview.key.command != request.command - { + if !preview.key.same_target(&request) { return None; } let result = preview.result.as_ref()?; @@ -1980,6 +2020,7 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { /// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto /// another document (`layer_transfer`). pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + crate::distort_ui::drop_foreign(app); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); // Once a frame, not per document view: the right-click or options-bar Brush Preset picker. @@ -2013,7 +2054,9 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if n == 0 && !opening { // Auto show the Home Screen is off: an empty workspace, like Photoshop. - paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + let area = ui.available_rect_before_wrap(); + paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); return; } if !app.ui.view.hides_tabs() || opening { @@ -2596,9 +2639,10 @@ pub fn fmt_zoom(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") } } -/// Repeating dot-grid texture for the canvas surround. +/// Repeating dot-grid texture for the canvas surround. A theme whose dots are its canvas colour +/// has no pattern to show, so it gets no texture at all rather than an invisible one (#4542). fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option { - if t.bevel { + if t.bevel || t.canvas_dot == t.canvas { return None; } let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind))); @@ -2647,12 +2691,24 @@ pub fn mode_label(doc: &Document) -> &'static str { } } +/// With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +/// (#5367), and so does the Home screen's blank background. Registered before anything drawn over +/// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop. +fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { + if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() { + let _ = app.open_dialog_file(); + } +} + fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); // File › Open Recent, newest first (on the web there are no paths to reopen). - let recent: Vec = if cfg!(target_arch = "wasm32") { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; + let show_recent = app.session.prefs().interface.show_home_recent_files; + let recent: Vec = + if cfg!(target_arch = "wasm32") || !show_recent { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; let recent_h = if recent.is_empty() { 0.0 } else { 34.0 + recent.len() as f32 * HOME_RECENT_ROW }; let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0 + recent_h)); let new_label = crate::shortcuts::command_label(app, "New document…", "file.new"); @@ -3087,14 +3143,24 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document // to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there. let px = |v: f64| v.clamp(-f64::from(1 << 30), f64::from(1 << 30)) as i32; - let vis = photocraft_geom::Rect::new( - px(x0.floor()).div_euclid(q) * q - q, - px(y0.floor()).div_euclid(q) * q - q, - (px(x1.ceil()).div_euclid(q) + 2) * q, - (px(y1.ceil()).div_euclid(q) + 2) * q, - ); + // A Free Transform carries the selection to wherever its box goes (#5297), so all of + // it is outlined: the part now in view may have started out of it. + let carried = app.session.active_index() == Some(idx) && crate::transform_tool::carries_selection_of(app, doc.id); + let vis = if carried { + photocraft_geom::Rect::new(-(1 << 30), -(1 << 30), 1 << 30, 1 << 30) + } else { + photocraft_geom::Rect::new( + px(x0.floor()).div_euclid(q) * q - q, + px(y0.floor()).div_euclid(q) * q - q, + (px(x1.ceil()).div_euclid(q) + 2) * q, + (px(y1.ceil()).div_euclid(q) + 2) * q, + ) + }; + let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; + let carried_bounds = carried.then(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); + let step = carried_bounds.map_or(step, |b| crate::outline::bounded_step(b, step)); let key = crate::surface_fingerprint(sel) - ^ (step as u64) << 56 + ^ u64::from(step).rotate_left(56) ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 @@ -3104,8 +3170,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key); if !fresh { let t0 = crate::gpu_canvas::now_ms(); - let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; - let b = app.cached_bounds(bounds_key, sel).intersect(&vis); + let b = carried_bounds.unwrap_or_else(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); // Filters can occupy every worker in Rayon's global pool. A parallel scan here would // block the UI thread waiting for those workers, preventing job cancellation (#1554). let mut segs = @@ -3126,7 +3191,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some((dx, dy)) = selection_shown_offset(app) { shown.center = [xf.center[0] - dx as f32, xf.center[1] - dy as f32]; } - marching_ants_segments(&painter, &shown, segs, time); + // Under a Free Transform box they are where the box puts them (#5297). + if !(carried && crate::transform_tool::draw_selection(app, &painter, &xf, doc.id, segs, time)) { + marching_ants_segments(&painter, &shown, segs, time); + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } @@ -3144,12 +3212,18 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::spring_tools::note_canvas_press(app); } // Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer. - let wheel = - crate::wheel_nav::read(&ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch); // The wheel also scrolls over the scrollbars drawn on top of the canvas (last frame's hover). let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); - if response.hovered() || free_hover || over_bars { + let nav_hovered = response.hovered() || free_hover || over_bars; + let wheel = crate::wheel_nav::read( + &ctx, + app.session.prefs().general.zoom_with_scroll_wheel, + app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, + response.id.with("wheel"), + nav_hovered, + ); + if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); match (wheel, pointer) { (Some(crate::wheel_nav::Wheel::Zoom(f)), Some(p)) => { @@ -3162,6 +3236,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: zoom_about(&mut view, &xf, at, nz, false, ppp); } } + (Some(crate::wheel_nav::Wheel::SnapZoom(n)), Some(p)) => { + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = view.zoom; + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step(nz, dir, view.doc_size); + } + if nz != view.zoom { + zoom_about(&mut view, &xf, p, nz, false, ppp); + } + } (Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => { let d = xf.unmap_vec(scroll) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3387,10 +3471,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::lasso_ui::canvas_input(app, &ctx, &xf, &response); (buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false); } + // Right-click on a guide where it could be dragged (the Move tool, or ⌘ with a selection + // tool): select it and open the guide menu, as Photoshop does. + let guide_menu = response.secondary_clicked() + && !lasso_retracted + && !transforming + && response.interact_pointer_pos().is_some_and(|p| crate::canvas_tool_menu::open_guide(app, &ctx, tool, mods, [p.x, p.y], xf.to_doc(p))); // Right-click with the Move tool, or ⌘/Ctrl+right-click: the layers under the pointer. if response.secondary_clicked() && !lasso_retracted && !transforming + && !guide_menu && crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { @@ -3404,6 +3495,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if response.secondary_clicked() && !lasso_retracted && !transforming + && !guide_menu && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { @@ -3581,6 +3673,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt), // The first click placed (or grabbed) the point; the second toggles it. Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]), + // Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop. + Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => { + if let Err(e) = crate::menus::invoke(app, &ctx, "edit.preferences.guidesGridAndSlices", json!({})) { + app.ui.status = e; + } + } _ => { if tool == Tool::Move && app.ui.transform.is_none() { begin_transform_controls_at(app, &ctx, &xf, p); @@ -3659,6 +3757,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); if primary && !(hand_pan && response.dragged()) { + // The transform preview is image content: it goes under the grid and guides, which + // stay visible across a layer being transformed (#5086); the box is drawn later. + crate::transform_tool::draw_content(app, &painter, &xf); if app.ui.extras.grid && app.ui.view.extras { crate::rulers::draw_grid(app, &painter, &xf, &doc); } @@ -3719,9 +3820,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: command_guide_at(app, tool, d, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers))) }); if let Some((vertical, _)) = guide_hover { - ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + ui.ctx().set_cursor_icon(crate::rulers::guide_cursor(&xf, vertical)); } else if let Some(p) = response.hover_pos() - && let Some(c) = crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) + && let Some(c) = crate::transform_tool::cursor(app, &xf, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) { ui.ctx().set_cursor_icon(c); // CursorIcon::None hides the pointer; the curved arrow has to be drawn or the @@ -3735,6 +3836,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) { ui.ctx().set_cursor_icon(c); + } else if let Some(p) = response.hover_pos().filter(|p| crate::corner_radius::hovers(app, xf.to_doc(*p))) { + // Over a live rectangle's corner widget (or dragging one): the arrow with a rounded + // corner badge, as Photoshop's corner radius cursor. + ui.ctx().set_cursor_icon(egui::CursorIcon::Default); + crate::icons::cursor_badge(&ctx, "scan", p); } else if let Some((r, p, c)) = response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { @@ -3749,7 +3855,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_transform::cursor(app, &ctx, &xf, p, mods) }) { ui.ctx().set_cursor_icon(c); - } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Artboard).and_then(|p| crate::artboard_tool::cursor(app, xf.to_doc(p))) { + } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Artboard).and_then(|p| crate::artboard_tool::cursor(app, &xf, xf.to_doc(p))) { ui.ctx().set_cursor_icon(c); } else if let Some((p, c)) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| Some((p, crate::crop_ui::cursor(app, xf.to_doc(p))?))) { ui.ctx().set_cursor_icon(c); @@ -3941,6 +4047,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } if primary && hand_pan && response.dragged() { + crate::transform_tool::draw_content(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); } app.tool_override = None; @@ -4116,6 +4223,54 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate /// [`marching_ants_segments`] with each outline point placed on screen by `to_screen` (a /// transformed outline: Content-Aware Move's Transform On Drop). pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) { + marching_ants_lines(painter, segs.iter().map(|(a, b)| (to_screen(*a), to_screen(*b))), time); +} + +/// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. +fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { + // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation + // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. + let (ax, ay) = (f64::from(a.x), f64::from(a.y)); + let (dx, dy) = (f64::from(b.x) - ax, f64::from(b.y) - ay); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, ax - f64::from(r.min.x)), (dx, f64::from(r.max.x) - ax), (-dy, ay - f64::from(r.min.y)), (dy, f64::from(r.max.y) - ay)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + let at = |t: f64| pos2((ax + dx * t) as f32, (ay + dy * t) as f32); + (t0 <= t1).then(|| (at(t0), at(t1))) +} + +#[cfg(test)] +mod transform_outline_clip_tests { + use super::*; + + #[test] + fn near_horizon_lines_keep_viewport_precision() { + let r = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + for (a, b, start, end) in [ + (pos2(-1e9, 100.0), pos2(1e9, 100.0), pos2(0.0, 100.0), pos2(800.0, 100.0)), + (pos2(100.0, -1e9), pos2(100.0, 1e9), pos2(100.0, 0.0), pos2(100.0, 600.0)), + (pos2(-1e9, -1e9), pos2(1e9, 1e9), pos2(0.0, 0.0), pos2(600.0, 600.0)), + ] { + let (x, y) = clip_line(r, a, b).unwrap(); + assert!(x.distance(start) < 0.01 && y.distance(end) < 0.01, "{x:?}–{y:?}, expected {start:?}–{end:?}"); + } + assert!(clip_line(r, pos2(-1e9, -100.0), pos2(1e9, -100.0)).is_none()); + } +} + +/// Marching ants along lines already placed on screen (a warped outline is a run of short ones). +pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator, time: f64) { + // Longer than any line that fits the view: only its part in view is dashed. + const LONG: f32 = 8192.0; let dash = 4.0f32; let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; let clip = painter.clip_rect(); @@ -4133,12 +4288,17 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli mesh.add_triangle(i, i + 1, i + 2); mesh.add_triangle(i, i + 2, i + 3); }; - for (a, b) in segs { - let pa = to_screen(*a); - let pb = to_screen(*b); - if !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + for (pa, pb) in lines { + // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). + if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { continue; } + let (pa, pb) = if pa.distance(pb) > LONG { + let Some(inside) = clip_line(clip.expand(4.0 * dash), pa, pb) else { continue }; + inside + } else { + (pa, pb) + }; let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); let len = pa.distance(pb); @@ -4514,9 +4674,15 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option Option { - if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() { + // A corner radius drag hides the box, as in Photoshop. + if app.ui.tool != Tool::Move + || !app.ui.tool_options.move_show_transform + || app.ui.transform.is_some() + || app.drag.is_some() + || app.ui.corner_radius.is_some() + { return None; } let st = app.session.active()?; @@ -4524,7 +4690,7 @@ fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option if crate::doc_props_ui::is_background(&st.doc, l) { return None; } - let b = photocraft_engine::transform_cmds::transform_bounds(&st.doc, l); + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, l); let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -4846,7 +5012,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: crate::vector_ui::pen_break_handle(app); } if tool == Tool::Pen { - crate::vector_ui::pen_move(app, x, y); + crate::vector_ui::pen_move(app, x, y, mods.shift); } else if tool == Tool::CurvaturePen { crate::vector_ui::curvature_move(app, x, y); } @@ -4872,6 +5038,13 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + // A press anywhere but on a guide deselects the selected guide (`guide_select`). + if let ToolEvent::Down { x, y, .. } = ev + && app.ui.selected_guide.is_some() + && crate::rulers::guide_at(app, x, y).is_none() + { + crate::guide_select::clear(app); + } if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return; } @@ -4933,6 +5106,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs). let ev = crate::move_mods::filter_event(app, ev, mods); + // A live rectangle's corner radius widgets (Path Selection, Rectangle tool). + if crate::corner_radius::pointer(app, ev, mods) { + return; + } // Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790). if crate::direct_select::pointer(app, ev, mods) { return; @@ -4969,7 +5146,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { - app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } }); + app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); crate::snap_ui::begin_guide(app, [x, y]); return; } @@ -5013,7 +5190,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) match ev { ToolEvent::Down { x, y, pressure } if tool == Tool::Move => { if let Some((vertical, i)) = crate::rulers::guide_at(app, x, y) { - app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } }); + // ⌥-click turns the guide, through the point clicked, as in Photoshop. + if mods.alt && !mods.shift && !mods.command { + crate::guide_select::flip(app, vertical, i, [x, y]); + return; + } + app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); return; } // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first @@ -5152,7 +5334,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { - crate::vector_ui::pen_down(app, x, y); + crate::vector_ui::pen_down(app, x, y, mods.shift); } return; } @@ -5759,6 +5941,11 @@ pub fn commit_crop(app: &mut PhotocraftApp) { let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0)); let delete = app.ui.tool_options.crop_delete; let mut p = json!({"x": x, "y": y, "width": w, "height": h, "deleteCroppedPixels": delete}); + // Fill › Content-Aware Fill (#3823): what a turned or extending frame uncovers is filled from + // its surroundings instead of the background colour. + if app.ui.tool_options.crop_content_aware { + p["contentAware"] = json!(true); + } // A turned frame (#1792): the document turns with it, then is cropped to it. (The untouched // default frame above is never turned: turning it makes it a real frame.) let angle = crate::crop_ui::angle(app); @@ -5812,6 +5999,22 @@ mod tabs_tests; #[cfg(test)] mod tests { + /// #5472: a ⇧ square pressed between pixels snapped to 101 × 102; the short side grows to + /// match, on the side the drag points to. + #[test] + fn square_px_equalises_the_sides_away_from_the_press_point() { + use super::{marquee_px, square_px}; + // Down-right drag: the continuous square (50.3, 50.7)..(150.8, 151.2) snaps unevenly. + let r = marquee_px([50.3, 50.7], [150.8, 151.2]); + assert_eq!(r, [50.0, 50.0, 151.0, 152.0], "uneven before the fix"); + assert_eq!(square_px(r, [50.3, 50.7], [150.8, 90.0]), [50.0, 50.0, 152.0, 152.0]); + // Up-left drag: the short side grows towards lower coordinates. + let r = marquee_px([150.8, 151.2], [50.3, 50.7]); + assert_eq!(square_px(r, [150.8, 151.2], [60.0, 50.7]), [49.0, 50.0, 151.0, 152.0]); + // Already square: unchanged. + assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. @@ -5905,57 +6108,225 @@ mod tests { assert!(app.filter_preview.as_ref().unwrap().result.is_some()); } + // Starts a worker job that computes a real preview for the dialog as it stands, but only + // finishes when the returned sender is signalled. Gating it here keeps these tests off the + // clock: `supersede` cancels a running job once the values have been still for SETTLE_MS, so a + // test that relies on a real blur beating a 150 ms timer is a test that fails on a loaded box. + #[cfg(not(target_arch = "wasm32"))] + fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::sync::mpsc::Sender<()>, f64) { + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let command = "filter.blur.gaussianBlur"; + let params = app.with_mask_target(command, crate::filter_dialog::params_of(&d.fields)); + assert_eq!( + crate::filter_dialog::preview_factor(command, ¶ms, 1), + 1, + "a 64x48 document previews at full resolution, and these tests rely on k staying 1" + ); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: command.into(), + params, + k: 1, + }; + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let radius = key.params["radius"].as_f64().expect("the dialog seeds a radius"); + let (release, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key.clone(), ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + let result = + crate::filter_dialog::preview_document_for_target(&doc, key.active, &key.command, &key.params, key.k, None, key.target, &documents).map( + |d| { + let buffer = photocraft_compose::flatten(&d); + (std::sync::Arc::new(d), buffer) + }, + ); + crate::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + (release, radius) + } + + // The worker hands its result to the channel only after `compute` returns, so a signal sent + // from inside `compute` would still race that send. These tests drive the canvas until the + // state they need appears instead of assuming a timing. + #[cfg(not(target_arch = "wasm32"))] + fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: impl Fn(&PhotocraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !done(app) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}"); + ensure_filter_preview(app, 0, ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn indexed_preview_discards_a_finished_job_after_slider_change_or_reopening() { - for reopen in [false, true] { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - app.background_jobs = true; - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - let d = app.ui.dialogs.first().unwrap(); - let st = app.session.active().unwrap(); - let old = crate::filter_dialog::FilterPreviewKey { - doc: st.doc.id, - revision: st.revision, - dialog: d.id, - active: st.active_layer, - target: st.channel_view.target, - command: "image.mode.indexedColor".into(), - params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), - k: 1, - }; - let (release, wait) = std::sync::mpsc::channel(); - let ctx = egui::Context::default(); - app.filter_preview_worker - .start(old.clone(), ctx.clone(), move |_| { - wait.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); - crate::filter_preview_worker::Computed { result: None, ms: 0.0 } - }) - .unwrap(); - if reopen { - app.ui.dialogs.clear(); - crate::filter_preview_worker::discard_closed(&mut app); - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - } else { - app.ui.dialogs.first_mut().unwrap().fields.insert("colors".into(), json!(16)); - } - assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none()); - assert!(app.filter_preview.is_none()); - release.send(()).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while app.filter_preview.is_none() { - assert!(std::time::Instant::now() < deadline); - ensure_filter_preview(&mut app, 0, &ctx); - if let Some(preview) = &app.filter_preview { - assert_ne!(preview.key, old, "an outdated preview was published"); - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert_eq!(app.session.active().unwrap().doc.mode, photocraft_color::ColorMode::Rgb); + fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { + // A drag used to paint nothing at all: every completion was dropped for carrying stale + // parameters, so the canvas only ever showed an effect once the pointer had come to rest. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let (release, first) = gated_blur_preview(&mut app, &ctx); + + // The pointer moves on before the frame lands. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the mid-drag frame", |a| a.filter_preview.is_some()); + + // The finished frame is painted even though the dialog has moved on. + let shown = app.filter_preview.as_ref().expect("the mid-drag frame was published"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "the frame is the one the worker finished"); + assert!(shown.result.is_some()); + assert_eq!(app.ui.dialogs[0].fields["radius"].as_f64(), Some(moved), "the slider kept moving"); + // The display gate lets it through, so the canvas has something to draw: this is what + // re-adding `params` to that gate would break. + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_some(), "the canvas draws the mid-drag frame"); + + // And the preview still converges on the value the dialog holds. `fresh` has to stay an + // exact match for that: relaxed, the canvas would believe it already had the current + // request after the first frame of a drag and never compute again. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.filter_preview.as_ref().is_none_or(|p| p.key.params["radius"].as_f64() != Some(moved)) { + assert!(std::time::Instant::now() < deadline, "the preview never converged on the current radius"); + ensure_filter_preview(&mut app, 0, &ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_frame_does_not_replace_a_frame_that_is_already_exact() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + // A frame for the radius the dialog holds right now reaches the canvas. This also proves + // the hand-built key is the one the canvas builds: it can only be published by the exact + // arm, which requires `finished == request`. + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert_eq!(app.filter_preview.as_ref().expect("published").key.params["radius"].as_f64(), Some(first)); + + // The slider moves away and a frame for the new radius is computed... + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let (release_moved, _) = gated_blur_preview(&mut app, &ctx); + // ...and back to `first` before that frame lands. + app.ui.dialogs[0].fields.insert("radius".into(), json!(first)); + release_moved.send(()).expect("released"); + + // The frame for `moved` arrives late. It belongs to this preview, but the frame on screen + // is already exact for the dialog, so it must never be shown in place of it. Checked on + // every pass: a wrong frame published and then overwritten before the loop ends would + // otherwise never be seen. + drive_until(&mut app, &ctx, "the late frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert_ne!(shown.key.params["radius"].as_f64(), Some(moved), "a late frame replaced an exact one"); + } + !a.filter_preview_worker.busy() + }); + let shown = app.filter_preview.as_ref().expect("a frame is on screen"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "a late frame replaced an exact one"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_empty_result_leaves_the_preview_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert!(app.filter_preview.as_ref().expect("published").result.is_some()); + + // The slider moves and a job for the new radius is started, then the slider moves again + // before that job lands: its result is stale by the time it arrives. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": moved}), + k: 1, + }; + let (release_empty, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key, ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("released"); + crate::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved + 4.0)); + release_empty.send(()).expect("released"); + + // Storing an empty result here would drop the canvas back to the unfiltered layer for a + // frame. The exact request may still store one; a stale one may not. Checked on every + // pass, because the reopened preview that follows would otherwise repair the symptom. + drive_until(&mut app, &ctx, "the empty frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert!(shown.result.is_some(), "an empty mid-drag result blanked the canvas"); + } + !a.filter_preview_worker.busy() + }); + assert!(app.filter_preview.as_ref().expect("a preview is still shown").result.is_some(), "an empty mid-drag result blanked the canvas"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_reopened_dialog_never_paints_the_previous_dialogs_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let first_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + let (release, _) = gated_blur_preview(&mut app, &ctx); + + // Close that dialog and open another one for the same layer and the same command. Only the + // dialog id differs, which is exactly what `same_target` has to notice. + app.ui.dialogs.clear(); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let second_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + assert_ne!(second_dialog, first_dialog, "a reopened dialog gets a new id"); + release.send(()).expect("released"); + + // The first dialog's frame arrives late and must not be painted under the second one. + // `discard_closed` usually reclaims it a frame earlier, which is why this drives the gate + // directly: the id check is the part that has to hold when that cleanup misses. The + // invariant is checked on every pass, not only at the end, because a wrongly published + // frame can still be replaced by the reopened dialog's own preview before we stop. + drive_until(&mut app, &ctx, "the first dialog's frame to arrive", |a| { + if let Some(preview) = &a.filter_preview { + assert_ne!(preview.key.dialog, first_dialog, "a reopened dialog painted the previous dialog's preview"); + } + !a.filter_preview_worker.busy() + }); + } + #[test] fn windows_uses_a_visible_crosshair() { // #737: Windows' inverting crosshair vanishes over mid-grey; use a black/white glyph. @@ -6027,6 +6398,32 @@ mod tests { assert_ne!(ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0, a, "reopening a preserved ID creates a fresh texture"); } + #[test] + fn only_themes_with_a_dot_pattern_tile_the_canvas_surround() { + // #4542: switching to Pro filled the empty workspace with red blocks on one GPU. Pro's + // dots are its canvas colour, so the tiled texture had nothing to show in the first place. + use crate::theme::ThemeKind; + let tiles = |ctx: &egui::Context, kind: ThemeKind| { + crate::theme::apply(ctx, kind); + let mut output = ctx.run_ui(Default::default(), |ui| paint_dots(ui, ui.max_rect(), true)); + output.textures_delta.clear(); + output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))) + }; + let uploaded = |ctx: &egui::Context| ctx.tex_manager().read().allocated().filter(|(_, meta)| meta.name == "canvas-dots").count(); + for kind in ThemeKind::ALL { + let ctx = egui::Context::default(); + let dotted = matches!(kind, ThemeKind::Studio | ThemeKind::StudioLight | ThemeKind::SolarizedDark); + assert_eq!(tiles(&ctx, kind), dotted, "{kind:?}"); + assert_eq!(uploaded(&ctx), usize::from(dotted), "{kind:?}"); + } + // The reported switch: a dotted theme, then Pro. + let ctx = egui::Context::default(); + assert!(tiles(&ctx, ThemeKind::Studio)); + assert!(!tiles(&ctx, ThemeKind::Pro)); + assert!(!tiles(&ctx, ThemeKind::ProMedium)); + assert_eq!(uploaded(&ctx), 1, "only Studio's dots were uploaded"); + } + #[test] fn closed_documents_release_cpu_canvas_textures() { let mut app = app_on_two_displays(); @@ -6576,6 +6973,30 @@ mod tests { assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); } + /// #4382: Interface › Show home recent files off hides the Home screen's list, not the recents. + #[test] + fn home_recent_list_hides_when_the_preference_is_off() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent("/tmp/photocraft-217/hidden.psd"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + assert!(h.query_by_label("hidden.psd").is_some(), "listed by default"); + h.state_mut().0.run("prefs.set", serde_json::json!({"values": {"interface.showHomeRecentFiles": false}})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("hidden.psd").is_none(), "hidden when off"); + assert_eq!(h.state().0.ui.recent_files.len(), 1, "the list itself is kept"); + } + /// The Home screen's × takes a file off Recent without opening it, and its right-click menu /// removes one entry or clears the list; the preferences keep the change (#2691). #[test] @@ -7340,6 +7761,51 @@ mod tests { assert_ne!(dragged.center, center0, "a secondary window's Hand pans while transforming"); } + #[test] + fn guides_draw_over_the_transform_preview_and_under_its_box() { + // #5086: a guide crossing a layer under Free Transform was hidden behind the preview of + // the moving pixels. The preview is image content, drawn before the grid and guides like + // the document is; the box and handles still go over them, as in Photoshop. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 150})).unwrap(); + app.ui.extras.guides = true; + app.ui.extras.rulers = false; + app.sync_views(); + crate::transform_tool::begin(&mut app, &ctx).expect("a transform starts"); + let mut view = app.ui.views[0].clone(); + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let input = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + canvas_view(&mut app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + // At 100 % centred on the document, screen and document coordinates coincide. + let layer = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 160.0)); + let near = |r: Rect| (r.min - layer.min).length() < 1.0 && (r.max - layer.max).length() < 1.0; + let position = |pred: &dyn Fn(&egui::Shape) -> bool| out.shapes.iter().position(|s| pred(&s.shape)); + // The moving pixels: the mesh that spans the layer's box. + let preview = position(&|s| matches!(s, egui::Shape::Mesh(m) if near(m.calc_bounds()))); + // The guide: a vertical line at x = 150. + let guide = position(&|s| matches!(s, egui::Shape::LineSegment { points, .. } if points.iter().all(|p| (p.x - 150.0).abs() < 0.01))); + // The box: the closed four-point outline round the layer. + let frame = position(&|s| matches!(s, egui::Shape::Path(p) if p.closed && p.points.len() == 4 && near(Rect::from_points(&p.points)))); + let (Some(preview), Some(guide), Some(frame)) = (preview, guide, frame) else { + panic!("all painted: preview {preview:?}, guide {guide:?}, box {frame:?}"); + }; + assert!(preview < guide, "the guide ({guide}) draws over the transform preview ({preview})"); + assert!(guide < frame, "the box ({frame}) draws over the guide ({guide})"); + } + #[test] fn brush_drags_paint_no_stand_in_shape_over_the_canvas() { // #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide @@ -7682,6 +8148,27 @@ mod transform_controls_tests { assert_eq!(app.ui.transform.as_ref().unwrap().rect, [200.0, 150.0, 300.0, 210.0]); } + /// #5297: with a selection the Move tool's transform controls are the selection's rectangle, + /// though the layer fills only part of it, and a press on them opens that box. + #[test] + fn move_transform_controls_frame_the_selection() { + 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("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 60, "y": 80, "width": 100, "height": 120})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [200.0, 150.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let r = transform_controls_rect(&app, &xf).unwrap(); + assert_eq!(r, Rect::from_min_max(pos2(60.0, 80.0), pos2(160.0, 200.0))); + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom())); + assert_eq!(app.ui.transform.as_ref().unwrap().rect, [60.0, 80.0, 160.0, 200.0]); + } + /// #2585: Free Transform on a position-locked layer is refused before the box opens, with /// Photoshop's alert, from ⌘T and from the Move tool's transform controls alike. It used to /// open, preview every drag and fail only at commit. diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index e1466f09ad..da835a786f 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,5 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free -//! Transform box, text being edited with a Type tool and the pasteboard (outside the image). +//! Transform box, text being edited with a Type tool, the pasteboard (outside the image) and a guide. use egui::Context; use photocraft_doc::LayerContent; @@ -31,6 +31,9 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// Opened on a guide: the guide menu (`guide_select`) instead of the tool's actions. + #[serde(default)] + pub guide: bool, /// The misspelled word under the pointer when the type edit menu opened: its suggestions head /// the menu. #[serde(default)] @@ -145,6 +148,23 @@ const ENDS_TYPE_EDIT: [&str; 5] = /// The pasteboard menu's Select Custom Color… row. pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; +/// The guide menu's Edit Selected Guides row: New Guide's dialog on the selected guide. +pub const GUIDE_EDIT: &str = "guides.editSelected"; +/// The guide menu's Clear Selected Guides row. +pub const GUIDE_CLEAR: &str = "guides.clearSelected"; + +/// Photoshop's guide menu (right-click on a guide), in its order. Its two Selected Guides rows act +/// on the guide the right-click selected; the rest are the View menu's. +pub const GUIDE_MENU: &[Row] = &[ + Some(("Edit Selected Guides", GUIDE_EDIT)), + None, + Some(("Lock Guides", "view.lockGuides")), + Some(("Clear Guides", "view.clearGuides")), + Some(("Clear Canvas Guides", "view.clearCanvasGuides")), + Some(("Clear Selected Guides", GUIDE_CLEAR)), + Some(("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides")), +]; + /// The pasteboard colours in the menu's order, which Space+F steps through. const CANVAS_COLORS: [CanvasColor; 6] = [CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom]; @@ -280,7 +300,9 @@ pub fn selection_rows(has_selection: bool) -> &'static [Row] { /// The rows of an open menu. pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { - if menu.pasteboard { + if menu.guide { + GUIDE_MENU + } else if menu.pasteboard { PASTEBOARD_MENU } else if menu.transform { TRANSFORM_MENU @@ -304,6 +326,9 @@ pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> b /// Scale stays Free Transform's mode (only Free Transform is checked). Rotate is its own mode. /// Warp is not a box mode (it is its own session), and `for_command` would read it as Free. pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.guide { + return command == "view.lockGuides" && app.ui.extras.lock_guides; + } if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); } @@ -393,6 +418,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); @@ -407,7 +433,15 @@ fn pasteboard_choice(command: &str) -> Option { /// The engine command (and params) a pasteboard row runs, for the automation policy: every row /// changes a preference. -pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { +pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> (String, Value) { + if menu.guide { + let selected = crate::guide_select::selected(app); + return match command { + GUIDE_EDIT => ("view.editGuide".into(), json!({})), + GUIDE_CLEAR => ("view.deleteGuide".into(), selected.map_or(json!({}), |(v, i)| crate::guide_select::delete_params(v, i))), + _ => (command.into(), json!({})), + }; + } 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!({})), @@ -457,12 +491,51 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true } +/// Right-click at document point `doc` on a guide that `tool` could drag (the Move tool, or ⌘ with +/// a selection tool): select the guide and open the guide menu at screen point `pos`. +pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui::Modifiers, pos: [f32; 2], doc: [f64; 2]) -> bool { + if !pos.iter().all(|v| v.is_finite()) || !doc.iter().all(|v| v.is_finite()) { + return false; + } + let hit = if tool == Tool::Move { crate::rulers::guide_at(app, doc[0], doc[1]) } else { crate::canvas::command_guide_at(app, tool, doc, mods) }; + let Some((vertical, index)) = hit else { return false }; + crate::guide_select::select(app, vertical, index); + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection: false, + has_path: false, + path_name: None, + transform: false, + pasteboard: false, + anchor: None, + text: false, + guide: true, + spelling: None, + }); + crate::canvas_popup::mark_open(ctx, menu_id()); + true +} + +/// A guide row: the two Selected Guides rows act on the selected guide; the rest are the View +/// menu's commands. +fn choose_guide(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> { + match command { + GUIDE_EDIT => crate::new_guide_ui::open_edit(app).map(|_| ()), + GUIDE_CLEAR => crate::guide_select::delete_selected(app).map(|_| ()), + _ => crate::menus::invoke(app, ctx, command, json!({})).map(|_| ()), + } +} + /// A pasteboard row: set the colour, or open the Color Picker on the custom one. fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { if command == PASTEBOARD_PICK { @@ -470,11 +543,17 @@ fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &s crate::color_picker_ui::open_for_target(app, "pasteboard", "Color Picker (Custom Canvas Color)", rgb); return Ok(()); } - let (command, params) = engine_call(menu, command); + let (command, params) = engine_call(app, menu, command); app.run(&command, params).map(|_| ()) } pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.guide { + return match command { + GUIDE_EDIT | GUIDE_CLEAR => crate::guide_select::selected(app).is_some(), + _ => GUIDE_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command), + }; + } if menu.pasteboard { return PASTEBOARD_MENU.iter().flatten().any(|(_, id)| *id == command); } @@ -549,7 +628,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 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 }; app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, spelling }); + Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, guide: false, spelling }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -572,6 +651,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); @@ -609,7 +689,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { if !available(app, &menu, command) { return; } - let result = if menu.pasteboard { + let result = if menu.guide { + choose_guide(app, ctx, command) + } else if menu.pasteboard { choose_pasteboard(app, &menu, command) } else if menu.anchor.is_some() { choose_view(app, ctx, &menu, command) @@ -635,7 +717,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { /// A spelling row: replace the word under the pointer (the caret lands after the replacement), or /// add it to the user dictionary. fn choose_spelling(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { - let (id, params) = engine_call(menu, command); + let (id, params) = engine_call(app, menu, command); let result = app.run(&id, params)?; if let Some(end) = result["end"].as_u64().map(|e| e as usize) && let Some(e) = app.ui.text_edit.as_mut() @@ -1374,7 +1456,7 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } crate::vector_ui::pen_commit(&mut app, true); @@ -1396,7 +1478,7 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } assert!(app.session.active().unwrap().doc.work_path.is_none()); @@ -1536,7 +1618,7 @@ mod tests { app.ui.tool = Tool::Pen; app.ui.tool_options.vector_mode = "shape".into(); for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } assert!(open(&mut app, &Context::default(), Tool::Pen, [10.0, 10.0])); diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index b8be54d273..5cbc960b8e 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -106,15 +106,25 @@ fn render_with_source( return None; } let layer_mask = v.shown_layer_mask(doc); - // ⌥-click mask view (#196): the layer mask alone, in grayscale (white reveals); ⇧⌥ with - // nothing else shown: just the rubylith. Both straight from the mask, in parallel rows, so - // switching the view on a 24 MP document takes one frame. + // ⌥-click mask view (#196): the pixel mask alone, in grayscale (white reveals); ⇧⌥ with + // nothing else shown: just the rubylith. Use the compositor's cached feather for the pixel + // mask, but leave vector masks out: this view targets the pixel mask channel by itself. + let feathered_mask = v + .layer_mask + .and_then(|view| doc.layer(view.layer)) + .filter(|layer| layer.mask.as_ref().is_some_and(|mask| mask.enabled && photocraft_compose::masks::feather_sigma(mask.feather) > 0.0)) + .and_then(|layer| { + let mut pixel_mask_layer = layer.clone(); + pixel_mask_layer.vector_mask = None; + photocraft_compose::masks::combined_mask(&pixel_mask_layer, doc.bounds()) + }); + let shown_mask = layer_mask.map(|(m, mode)| (feathered_mask.as_ref().unwrap_or(&m.surface), mode)); let only_mask = ChannelView { layer_mask: None, ..v.clone() }.is_plain(doc); - match layer_mask { - Some((m, MaskViewMode::Gray)) => return Some(mask_px(&m.surface, r, factor, |g| Color32::from_gray(q(g)))), + match shown_mask { + Some((m, MaskViewMode::Gray)) => return Some(mask_px(m, r, factor, |g| Color32::from_gray(q(g)))), Some((m, MaskViewMode::Overlay)) if only_mask => { let c = AlphaChannel::DEFAULT_COLOR.to_rgb(); - return Some(mask_px(&m.surface, r, factor, |g| { + return Some(mask_px(m, r, factor, |g| { let a = 0.5 * (1.0 - g.clamp(0.0, 1.0)); Color32::from_rgba_premultiplied(q(c[0] * a), q(c[1] * a), q(c[2] * a), q(a)) })); @@ -138,9 +148,9 @@ fn render_with_source( } else if let Some(q) = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden) { solo_quick = true; gray(&q.surface) - } else if let Some((m, _)) = layer_mask { + } else if let Some((m, _)) = shown_mask { solo_mask = true; - gray(&m.surface) + gray(m) } else { let m = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref())?; solo_mask = true; @@ -204,8 +214,8 @@ fn render_with_source( } // ⇧⌥-click mask view (#196): the layer mask as Quick Mask's red over the hidden areas // (Photoshop's Layer Mask Display Options default). - if !solo_mask && let Some((m, MaskViewMode::Overlay)) = layer_mask { - tint(sample(&m.surface, r, factor), AlphaChannel::DEFAULT_COLOR, 0.5, &|x| 1.0 - x); + if !solo_mask && let Some((m, MaskViewMode::Overlay)) = shown_mask { + tint(sample(m, r, factor), AlphaChannel::DEFAULT_COLOR, 0.5, &|x| 1.0 - x); } Some(out.into_iter().map(|p| Color32::from_rgba_premultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3]))).collect()) } @@ -513,6 +523,35 @@ mod tests { assert!(px[36].a() <= 4, "next to nothing over the revealed end: {:?}", px[36]); } + #[test] + fn layer_mask_views_show_the_feathered_mask() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 37, "height": 21})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [37, 0], "colors": ["#000000", "#ffffff"], "target": "mask"})).unwrap(); + s.execute("layer.layerMask.edit", json!({"feather": 4})).unwrap(); + s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); + + let st = s.active().unwrap(); + let bounds = st.doc.bounds(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let raw = layer.mask.as_ref().unwrap().surface.sample_channel(0, 0, 0); + let effective = photocraft_compose::masks::combined_mask(layer, bounds).unwrap(); + let softened = effective.sample_channel(0, 0, 0); + assert!((softened - raw).abs() > 0.01, "the fixture has a visible feather at its edge"); + + let gray = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + assert_eq!(gray[0], Color32::from_gray(q(softened)), "gray view uses the compositor's feathered mask"); + + s.execute("view.layerMask", json!({"mode": "overlay"})).unwrap(); + let st = s.active().unwrap(); + let overlay = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + let alpha = 0.5 * (1.0 - softened); + let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); + assert_eq!(overlay[0], Color32::from_rgba_premultiplied(q(red[0] * alpha), q(red[1] * alpha), q(red[2] * alpha), q(alpha))); + } + /// `cargo test --release -p photocraft-ui-egui --lib mask_view_switch_24mp -- --ignored --nocapture` #[test] #[ignore = "benchmark"] diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index b20e913a7d..bbdce8fa78 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -339,7 +339,7 @@ mod tests { let mut gray = doc(); gray.mode = photocraft_doc::ColorMode::Grayscale; gray.icc_profile = Some(photocraft_engine::color_cmds::working_profile(gray.mode).to_bytes()); - assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"); + assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)"); let mut custom = (*photocraft_engine::color_cmds::working_profile(photocraft_doc::ColorMode::Cmyk)).clone(); custom.description = "PhotoCraft Studio CMYK".into(); diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 0d51f5d2f5..09a3ce8d4c 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -685,9 +685,17 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo crate::field_tab::select_all(ui.ctx(), id, current_hex.trim_start_matches('#')); ui.memory_mut(|m| m.request_focus(id)); } - let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::(id)) } else { None }; - let mut h = typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()); + let focused = ui.memory(|m| m.has_focus(id)); + let pasted = if focused { take_hex_paste(ui) } else { None }; + let typing = if focused { ui.data(|d| d.get_temp::(id)) } else { None }; + let mut h = match pasted { + Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(), + None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()), + }; let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); + if let Some(c) = pasted { + edit = Some((c, Keep::Nothing)); + } if r.gained_focus() { crate::field_tab::select_all(ui.ctx(), id, &h); } @@ -743,6 +751,23 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } } +fn take_hex_paste(ui: &egui::Ui) -> Option<[f32; 3]> { + ui.input_mut(|i| { + let mut found = None; + i.events.retain(|e| match e { + egui::Event::Paste(s) => match parse_hex(s) { + Some(c) => { + found = Some(c); + false + } + None => true, + }, + _ => true, + }); + found + }) +} + /// Add to Swatches was clicked: add the picked colour to the Swatches panel. pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { if f.remove("__addSwatch").and_then(|v| v.as_bool()) == Some(true) { @@ -884,6 +909,23 @@ mod tests { assert_eq!(color(&h), "#ff8800"); } + #[test] + fn hex_field_takes_every_paste_with_or_without_hash() { + let mut h = picker("#336699"); + for (paste, want) in [("ff8800", "#ff8800"), ("#00ff00", "#00ff00"), (" 1234AB\n", "#1234ab")] { + h.event(egui::Event::Paste(paste.into())); + h.run_steps(2); + assert_eq!(color(&h), want, "paste {paste:?}"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some(&want[1..].to_ascii_uppercase()[..])); + } + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Paste("ff".into())); + h.run_steps(2); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("ff")); + assert_eq!(color(&h), "#1234ab"); + } + #[test] fn color_picker_opens_with_hex_focused_and_selected() { let mut h = picker("#336699"); diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 80417eeec8..59b3bfeb2f 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -649,9 +649,6 @@ pub fn pick_top(app: &mut PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> if !at[0].is_finite() || !at[1].is_finite() || at[0] < 0.0 || at[1] < 0.0 || at[0] >= f64::from(doc.size.width) || at[1] >= f64::from(doc.size.height) { return false; } - if doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) <= 0.0) { - return false; - } pick(app, &mut f, at, mods); match app.ui.dialogs.last_mut() { Some(d) => { @@ -708,9 +705,6 @@ pub fn pick(app: &PhotocraftApp, f: &mut Map, at: [f64; 2], mods: let (w, h) = (f64::from(st.doc.size.width.max(1)), f64::from(st.doc.size.height.max(1))); let [x, y] = at; let at = [x.floor().clamp(0.0, w - 1.0), y.floor().clamp(0.0, h - 1.0)]; - if st.doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0] as i32, at[1] as i32, 0) <= 0.0) { - return; - } let tool = if mods.shift { "add" } else if mods.alt { @@ -831,10 +825,12 @@ mod tests { assert_eq!(mask[5 * 40 + 25], 0.0, "even inverted previews stay within the selection"); } + // As in Photoshop, the eyedropper samples anywhere; only the result is scoped (#5244). open(&mut app); - assert!(!pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); - let fields = &app.ui.dialogs.last().unwrap().fields; - assert!(points(fields, "points").is_empty(), "a click outside the selection is ignored"); + assert!(pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); + assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[25.0, 5.0]], "a colour outside the selection is sampled"); + let mask = proxy_mask(&app, &doc, 1, &json!({"select": "sampledColors", "points": [[25, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(mask[5 * 40 + 25], 0.0, "but the preview stays within the selection"); assert!(pick_top(&mut app, [5.0, 5.0], egui::Modifiers::NONE)); assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[5.0, 5.0]]); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 209691b875..ff786be197 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,7 +131,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 35] = [ +pub const UI_SET_FIELDS: [&str; 37] = [ "tool", "panels", "dock", @@ -164,9 +164,11 @@ pub const UI_SET_FIELDS: [&str; 35] = [ "cropOverlayShow", "cropOverlayOrientation", "cropShield", + "cropFill", "shapeStroke", "camTransformOnDrop", "tileGrid", + "layerFilter", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -395,7 +397,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) return err("context action is unavailable"); } // A pasteboard row changes a preference: the policy sees the `prefs.set` it runs. - let (command, params) = crate::canvas_tool_menu::engine_call(menu, id); + let (command, params) = crate::canvas_tool_menu::engine_call(app, menu, id); if let Some(authorize) = app.services.automation_command.as_ref() && let Err(error) = authorize(&command, ¶ms) { @@ -511,6 +513,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?; // Developer overlay of the active layer's storage tiles (tile_overlay.rs). let tile_grid = bool_field(p, "tileGrid")?; + let layer_filter = p.get("layerFilter").map(|v| crate::layer_filter_ui::patched(&app.ui, v)).transpose()?; // The Crop tool's overlay menu (#1919). let crop_overlay = match p.get("cropOverlay") { Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| { @@ -532,6 +535,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) Some(_) => return Err("cropOverlayOrientation must be 0, 1, 2 or 3".into()), None => None, }; + // The Crop tool's Fill (#3823): "background" (Background (Default)) or "contentAware". + let crop_content_aware = match p.get("cropFill") { + Some(v) => Some(match v.as_str() { + Some("background") => false, + Some("contentAware") => true, + _ => return Err("cropFill must be \"background\" or \"contentAware\"".into()), + }), + None => None, + }; // The Crop tool's gear menu: Show Cropped Area and the shield (#1919). let crop_shield = merged_object(&app.ui.tool_options.crop_shield, p.get("cropShield"), "cropShield")?; if crop_shield.as_ref().is_some_and(|s| !(0.0..=100.0).contains(&s.opacity)) { @@ -642,7 +654,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } } if let Some(t) = tool { - app.ui.tool = t; + crate::tool_memory::select(app, t); // Each tool keeps its own brush (#218), so switch it in before `brushSize` // below sets the new tool's size. crate::paint_mouse::sync_tool_brush(app); @@ -672,9 +684,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = tile_grid { app.ui.view.tile_grid = on; } + if let Some(f) = layer_filter { + crate::layer_filter_ui::apply(&mut app.ui, f); + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } + if let Some(on) = crop_content_aware { + app.ui.tool_options.crop_content_aware = on; + } if let Some(v) = crop_overlay_show { app.ui.tool_options.crop_overlay_show = v; } @@ -985,7 +1003,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let secondary = matches!(button.as_str(), "secondary" | "right"); let mods = modifiers_from(&m); if let Some(t) = tool { - app.ui.tool = t; + crate::tool_memory::select(app, t); } // Space held: the Crop tool moves the frame being drawn, a marquee, lasso or shape // being drawn moves instead of growing (hold_keys.rs). Like a modifier flag, it may be @@ -1293,6 +1311,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "pos": menu.pos, "tool": menu.tool, "pasteboard": menu.pasteboard, + "guide": menu.guide, "entries": context_entries(app, menu) }) }), @@ -1391,7 +1410,11 @@ mod tests { assert!(app.ui.dialogs.is_empty()); let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); let original = app.session.active().unwrap().doc.clone(); - let changed = call(&mut app, &ctx, "ui.menu.invoke", update); + let changed = call(&mut app, &ctx, "ui.menu.invoke", update.clone()); + let undone = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.cameraRaw","params":{"ui":{"undo":true}}})); + assert_eq!(undone["result"]["params"]["exposure"], 0.0, "{undone}"); + let redone = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.cameraRaw","params":{"ui":{"redo":true}}})); + assert_eq!(redone["result"]["params"]["exposure"], 0.5, "{redone}"); assert_eq!(changed["ok"], true, "{changed}"); assert_eq!(changed["result"]["params"]["exposure"], 0.5); assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original), "a preview must not commit"); @@ -1926,6 +1949,36 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + /// #5048: the Layers panel filter is drivable; a bad field changes nothing. + #[test] + fn ui_set_drives_the_layers_panel_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "name", "name": "ink"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_name.as_str()), (FilterBy::Name, "ink")); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Pass Through", "kinds": ["type", "pixel", "type"]}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::PassThrough)); + assert_eq!(app.ui.layer_filter, ["type", "pixel"], "kinds are kept once each"); + assert_eq!(app.ui.layer_filter_name, "ink", "fields left out keep their value"); + for bad in [ + json!({"layerFilter": "name"}), + json!({"layerFilter": {"by": "colour"}}), + json!({"layerFilter": {"kinds": ["pixels"]}}), + json!({"layerFilter": {"kinds": "pixel"}}), + json!({"layerFilter": {"name": 3}}), + json!({"layerFilter": {"mode": "Nonsense"}}), + json!({"layerFilter": {"by": "kind", "extra": true}}), + json!({"layerFilter": {"by": "kind"}, "tileGrid": "yes"}), + ] { + let r = call(&mut app, &ctx, "ui.set", bad.clone()); + assert_eq!(r["ok"], false, "{bad} → {r}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Mode, "{bad} changed nothing"); + } + } + #[test] fn ui_set_toggles_the_developer_tile_grid() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1988,6 +2041,22 @@ mod tests { assert_eq!(Tool::ColorSampler.key(), 'I'); } + /// #3823: the Crop tool's Fill over the control channel, reported by `ui.inspect`. + #[test] + fn ui_set_drives_the_crop_fill() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "crop", "cropFill": "contentAware"}))["ok"], true); + assert!(app.ui.tool_options.crop_content_aware); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["toolOptions"]["crop_content_aware"], true); + for bad in [json!("generativeExpand"), json!(true), json!(1)] { + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": bad}))["ok"], false, "{bad}"); + assert!(app.ui.tool_options.crop_content_aware, "{bad}: unchanged"); + } + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": "background"}))["ok"], true); + assert!(!app.ui.tool_options.crop_content_aware); + } + /// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`. #[test] fn ui_set_drives_the_crop_overlay_options() { diff --git a/crates/ui-egui/src/corner_radius.rs b/crates/ui-egui/src/corner_radius.rs new file mode 100644 index 0000000000..b02e4d0eea --- /dev/null +++ b/crates/ui-egui/src/corner_radius.rs @@ -0,0 +1,232 @@ +//! On-canvas corner radius widgets for live rectangles (TOOL-214-12), as in Photoshop: with Path +//! Selection, the Rectangle tool, or the Move tool showing its transform controls, every selected +//! shape layer that is a live rectangle shows a round widget inside each corner, at the centre of +//! its arc. Dragging one rounds every corner of every selected rectangle to the same radius; +//! ⌥/Alt changes that corner only, on each of them. The drag previews the new outlines and +//! commits one `shape.edit` with `radii` per changed layer on release, as one history step; a +//! click without movement changes nothing. + +use egui::{Color32, Modifiers, Pos2, Stroke}; +use photocraft_doc::LayerContent; +use photocraft_doc::vector::LiveShape; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A widget sits at least this many screen points inside its corner, so a sharp corner's widget +/// can still be grabbed. +const MIN_INSET: f64 = 10.0; + +/// A selected live rectangle: its layer, `[x, y, w, h]` with a positive size, and its radii. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Target { + layer: u64, + rect: [f64; 4], + radii: [f64; 4], +} + +/// A corner radius drag in progress. +#[derive(Clone, Debug, PartialEq)] +pub struct Drag { + /// Every selected live rectangle, as at the press. + targets: Vec, + /// The rectangle whose widget was pressed (an index into `targets`) and its corner, + /// `[tl, tr, br, bl]` order. + grabbed: usize, + corner: usize, + /// Every corner (no ⌥) or the dragged one only. + all: bool, + /// Where the press was. + start: [f64; 2], + /// Each target's radii so far. + now: Vec<[f64; 4]>, +} + +/// Do the widgets show with the current tool? +fn shows(app: &PhotocraftApp) -> bool { + app.ui.transform.is_none() + && match app.ui.tool { + Tool::PathSelection | Tool::Rectangle => true, + Tool::Move => app.ui.tool_options.move_show_transform, + _ => false, + } +} + +/// The selected layers that are live rectangles, front first. +fn targets(app: &PhotocraftApp) -> Vec { + if !shows(app) { + return Vec::new(); + } + let Some(st) = app.session.active() else { return Vec::new() }; + let mut out: Vec = st + .selected_layers() + .into_iter() + .filter_map(|id| { + let l = st.doc.layer(id)?; + let LayerContent::Shape(sh) = &l.content else { return None }; + let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None }; + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; + (w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii }) + }) + .collect(); + // `selected_layers` lists bottom to top; the topmost widget wins a press. + out.reverse(); + out +} + +/// The largest radius a corner of `rect` takes. +fn max_radius(rect: [f64; 4]) -> f64 { + rect[2].min(rect[3]) / 2.0 +} + +/// Corner `i` of `rect` and the unit-step direction pointing inside from it. +fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) { + let [x, y, w, h] = rect; + match i { + 0 => ([x, y], [1.0, 1.0]), + 1 => ([x + w, y], [-1.0, 1.0]), + 2 => ([x + w, y + h], [-1.0, -1.0]), + _ => ([x, y + h], [1.0, -1.0]), + } +} + +/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a +/// sharper corner. +fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] { + let (c, d) = corner(rect, i); + let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect)); + [c[0] + d[0] * r, c[1] + d[1] * r] +} + +/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its +/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more), +/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each +/// rectangle then caps it at half its own shorter side. +fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 { + let (_, d) = corner(rect, i); + let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0; + if r.is_finite() { r.max(0.0) } else { from } +} + +fn zoom(app: &PhotocraftApp) -> f64 { + f64::from(app.point_zoom().max(0.01)) +} + +/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an +/// index into `targets`) and its corner, the nearest when several are close. +fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> { + let z = zoom(app); + let tol = 7.0 / z; + targets + .iter() + .enumerate() + .flat_map(|(t, g)| { + (0..4).map(move |i| { + let w = widget(g.rect, g.radii, i, z); + (t, i, (w[0] - p[0]).hypot(w[1] - p[1])) + }) + }) + .filter(|(.., d)| *d <= tol) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(t, i, _)| (t, i)) +} + +/// Is a widget under document point `p`, or one being dragged? The canvas then shows the corner +/// radius cursor. +pub fn hovers(app: &PhotocraftApp, p: [f64; 2]) -> bool { + app.ui.corner_radius.is_some() || hit(app, &targets(app), p).is_some() +} + +/// Canvas pointer events for the corner widgets; false when the event is not theirs. +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + match ev { + ToolEvent::Down { x, y, .. } => { + if mods.command { + return false; + } + let targets = targets(app); + let Some((grabbed, corner)) = hit(app, &targets, [x, y]) else { return false }; + let now = targets.iter().map(|t| t.radii).collect(); + app.ui.corner_radius = Some(Drag { targets, grabbed, corner, all: !mods.alt, start: [x, y], now }); + // The press may have started the Move tool's snapping, which a radius drag has none of. + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + app.move_mods = Default::default(); + true + } + ToolEvent::Move { x, y, .. } => { + let Some(d) = app.ui.corner_radius.as_mut() else { return false }; + let Some(g) = d.targets.get(d.grabbed) else { return true }; + let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect)); + let r = radius_after(g.rect, d.corner, from, d.start, [x, y]); + // Every selected rectangle takes the same radius, within its own size. + for (t, now) in d.targets.iter().zip(d.now.iter_mut()) { + let r = r.min(max_radius(t.rect)); + if d.all { + *now = [r; 4]; + } else { + *now = t.radii; + if let Some(v) = now.get_mut(d.corner) { + *v = r; + } + } + } + true + } + ToolEvent::Up { x, y } => { + if app.ui.corner_radius.is_none() { + return false; + } + pointer(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods); + let Some(d) = app.ui.corner_radius.take() else { return true }; + // One history step for every layer changed. + let key = format!("corner-radius-{}", app.ui.alloc_id()); + for (t, now) in d.targets.iter().zip(&d.now) { + if *now != t.radii + && let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})) + { + app.ui.status = e; + app.ui.status_error = true; + break; + } + } + true + } + } +} + +/// The radius the dragged corner shows in the readout. +fn dragged_radius(d: &Drag) -> Option { + d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied() +} + +/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the +/// radius beside the pointer (`R: 25.0 px`), as in Photoshop. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { + let drag = app.ui.corner_radius.as_ref(); + if let Some(r) = drag.and_then(dragged_radius) + && let Some(at) = painter.ctx().input(|i| i.pointer.hover_pos()) + { + crate::canvas::draw_value_readout(app, painter.ctx(), "corner-radius-readout", at, ["R:"], [format!("{r:.1} px")]); + } + let z = zoom(app); + for t in targets(app) { + let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i))); + let radii = now.copied().unwrap_or(t.radii); + if now.is_some() { + let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii); + crate::vector_ui::draw_outline(painter, &path, to_scr, accent); + } + for i in 0..4 { + let p = to_scr(widget(t.rect, radii, i, z)); + painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); + painter.circle_filled(p, 2.5, accent); + } + } +} + +#[cfg(test)] +#[path = "corner_radius_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/corner_radius_tests.rs b/crates/ui-egui/src/corner_radius_tests.rs new file mode 100644 index 0000000000..b0b47c45be --- /dev/null +++ b/crates/ui-egui/src/corner_radius_tests.rs @@ -0,0 +1,186 @@ +//! TOOL-214-12: a live rectangle's corner radius widgets, as in Photoshop. + +use egui::Modifiers; +use photocraft_doc::LayerContent; +use photocraft_doc::vector::LiveShape; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 200 × 200 document at 100 % with a live rectangle at (40, 40), 120 × 80, radii 10. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", json!({"kind": "roundedRect", "rect": [40, 40, 120, 80], "radii": 10})).unwrap(); + app.ui.tool = tool; + app +} + +fn radii(app: &PhotocraftApp) -> Option<[f64; 4]> { + let st = app.session.active()?; + let l = st.active_layer.and_then(|id| st.doc.layer(id))?; + match &l.content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => Some(radii), + _ => None, + }, + _ => None, + } +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +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 dragging_a_widget_rounds_every_corner_in_one_step() { + let mut app = app(Tool::PathSelection); + let n = steps(&app); + // The top-left widget sits at the arc centre (50, 50); dragging it 15 px along the diagonal + // adds 15 to the radius. + drag(&mut app, [50.0, 50.0], [65.0, 65.0], Modifiers::NONE); + assert_eq!(radii(&app), Some([25.0; 4])); + assert_eq!(steps(&app), n + 1, "one history step"); + assert!(app.ui.corner_radius.is_none()); + assert!(app.session.undo()); + assert_eq!(radii(&app), Some([10.0; 4])); +} + +#[test] +fn alt_drags_one_corner_and_radii_stay_within_half_the_short_side() { + let mut app = app(Tool::Rectangle); + // The bottom-right widget, with ⌥: only that corner, capped at 40 (half of the height 80). + drag(&mut app, [150.0, 110.0], [20.0, 20.0], Modifiers::ALT); + assert_eq!(radii(&app), Some([10.0, 10.0, 40.0, 10.0])); + // Dragging out past the corner makes it sharp. + drag(&mut app, [150.0, 50.0], [190.0, 10.0], Modifiers::ALT); + assert_eq!(radii(&app), Some([10.0, 0.0, 40.0, 10.0])); + // The Rectangle tool drew no new shape. + assert_eq!(app.session.active().unwrap().doc.layers.len(), 2); +} + +#[test] +fn a_sharp_corner_keeps_a_grabbable_widget_and_a_click_changes_nothing() { + let mut app = app(Tool::PathSelection); + let id = app.session.active().unwrap().active_layer.unwrap().0; + app.run("shape.edit", json!({"layer": id, "radii": 0})).unwrap(); + let n = steps(&app); + // At 100 % the widget of a sharp corner is 10 px inside it. + drag(&mut app, [50.0, 50.0], [50.0, 50.0], Modifiers::NONE); + assert_eq!((radii(&app), steps(&app)), (Some([0.0; 4]), n), "a click commits nothing"); + drag(&mut app, [50.0, 50.0], [60.0, 60.0], Modifiers::NONE); + assert_eq!(radii(&app), Some([10.0; 4])); +} + +#[test] +fn only_live_rectangles_with_the_right_tools_take_the_press() { + for tool in [Tool::Move, Tool::DirectSelection, Tool::Brush] { + let mut a = app(tool); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE), "{tool:?}"); + } + // ⌘/Ctrl leaves the press to the tool. + let mut a = app(Tool::PathSelection); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::COMMAND)); + // An ellipse has no corners. + let mut a = app_with(json!({"kind": "ellipse", "rect": [40, 40, 120, 80]})); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE)); + // A press off the widgets is the tool's. + let mut a = app(Tool::PathSelection); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE)); +} + +fn app_with(shape: serde_json::Value) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", shape).unwrap(); + app.ui.tool = Tool::PathSelection; + app +} + +/// Three live rectangles: (20, 20) 60 × 40 radii 5, (110, 20) 70 × 50 radii 0, and (20, 120) +/// 80 × 60 radii 0, the last one left unselected. Returns the ids, bottom first. +fn three(tool: Tool) -> (PhotocraftApp, Vec) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + let mut ids = Vec::new(); + for (rect, r) in [([20, 20, 60, 40], 5), ([110, 20, 70, 50], 0), ([20, 120, 80, 60], 0)] { + app.run("shape.create", json!({"kind": "rect", "rect": rect, "radii": r})).unwrap(); + ids.push(app.session.active().unwrap().active_layer.unwrap()); + } + app.run("layer.select", json!({"layer": ids[0].0})).unwrap(); + app.run("layer.select", json!({"layer": ids[1].0, "mode": "add"})).unwrap(); + app.ui.tool = tool; + (app, ids) +} + +fn radii_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> [f64; 4] { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => radii, + _ => panic!("live rect"), + }, + _ => panic!("shape"), + } +} + +#[test] +fn one_widget_rounds_every_selected_rectangle_alike_in_one_step() { + let (mut app, ids) = three(Tool::PathSelection); + let n = steps(&app); + // The second rectangle's top-left widget (sharp: 10 px inside, at (120, 30)), dragged 15 px + // in each direction: radius 15 on every corner of both selected rectangles. + drag(&mut app, [120.0, 30.0], [135.0, 45.0], Modifiers::NONE); + assert_eq!(radii_of(&app, ids[0]), [15.0; 4]); + assert_eq!(radii_of(&app, ids[1]), [15.0; 4]); + assert_eq!(radii_of(&app, ids[2]), [0.0; 4], "the unselected one is untouched"); + assert_eq!(steps(&app), n + 1, "one history step for both layers"); + assert!(app.session.undo()); + assert_eq!((radii_of(&app, ids[0]), radii_of(&app, ids[1])), ([5.0; 4], [0.0; 4])); +} + +#[test] +fn alt_changes_that_corner_on_each_and_each_stays_within_its_own_size() { + let (mut app, ids) = three(Tool::Rectangle); + // ⌥ on the first rectangle's top-left widget (radius 5, so drawn 10 px inside at (30, 30)), + // dragged far in: each rectangle's top-left corner only, capped at half of its own short side. + drag(&mut app, [30.0, 30.0], [100.0, 100.0], Modifiers::ALT); + assert_eq!(radii_of(&app, ids[0]), [20.0, 5.0, 5.0, 5.0]); + assert_eq!(radii_of(&app, ids[1]), [25.0, 0.0, 0.0, 0.0]); +} + +#[test] +fn the_move_tool_shows_and_drags_the_widgets_with_its_transform_controls() { + let (mut app, ids) = three(Tool::Move); + assert!(!super::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 30.0, pressure: 1.0 }, Modifiers::NONE), "not without the controls"); + app.ui.tool_options.move_show_transform = true; + let before = app.session.active().unwrap().selected_layers(); + drag(&mut app, [120.0, 30.0], [130.0, 40.0], Modifiers::NONE); + assert_eq!((radii_of(&app, ids[0]), radii_of(&app, ids[1])), ([10.0; 4], [10.0; 4])); + assert_eq!(app.session.active().unwrap().selected_layers(), before, "the press neither moved nor reselected layers"); +} + +#[test] +fn the_cursor_and_readout_follow_the_widgets() { + let mut app = app(Tool::PathSelection); + // The corner radius cursor shows over a widget, not elsewhere. + assert!(super::hovers(&app, [50.0, 50.0])); + assert!(!super::hovers(&app, [100.0, 80.0])); + // While dragging it stays, and the readout shows the dragged corner's radius. + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 62.0, y: 62.0, pressure: 1.0 }, Modifiers::NONE); + assert!(super::hovers(&app, [100.0, 80.0])); + assert_eq!(app.ui.corner_radius.as_ref().and_then(super::dragged_radius), Some(22.0)); + tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE); + assert!(!super::hovers(&app, [100.0, 80.0])); +} diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 54ec3795d2..78f8d44b50 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -881,8 +881,9 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { None => hit_turned(app.ui.crop_rect?, deg, p, tolerance(app)), }; Some(match h { - // The arrow as the handle faces on screen: the frame's turn plus the view's. - Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app))), + // The arrow as the handle faces on screen: the frame's turn plus the view's, mirrored when + // the view is flipped. + Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app)), app.ui.view.flip_horizontal), // Inside the untouched default frame a drag draws a new one (`pointer`), so nothing moves (#4461). Hit::Inside if app.crop.default_frame => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, @@ -947,9 +948,12 @@ pub fn draw_turn_cursor(ctx: &egui::Context, p: egui::Pos2, toward: egui::Vec2) } /// The resize arrow for handle (`hx`, `hy`) of a frame turned `deg` degrees: the handle's outward -/// direction, turned with the frame, to the nearest of the four arrows. -fn resize_icon(hx: i8, hy: i8, deg: f64) -> CursorIcon { +/// direction, turned with the frame and mirrored by a flipped view (`flip`), to the nearest of the +/// four arrows. +fn resize_icon(hx: i8, hy: i8, deg: f64, flip: bool) -> CursorIcon { let d = turn([f64::from(hx), f64::from(hy)], [0.0, 0.0], deg); + // View › Flip Horizontal mirrors the screen after the turn. + let d = if flip { [-d[0], d[1]] } else { d }; let a = d[1].atan2(d[0]).rem_euclid(std::f64::consts::PI); match ((a / std::f64::consts::FRAC_PI_4).round() as i64).rem_euclid(4) { 0 => CursorIcon::ResizeHorizontal, @@ -1203,10 +1207,10 @@ mod tests { let b = turned_bounds(r, 90.0); assert!((b[0] - 50.0).abs() < 1e-9 && (b[3] - 80.0).abs() < 1e-9, "{b:?}"); // Handle arrows turn with the frame. - assert_eq!(resize_icon(1, 0, 0.0), CursorIcon::ResizeHorizontal); - assert_eq!(resize_icon(1, 0, 90.0), CursorIcon::ResizeVertical); - assert_eq!(resize_icon(1, 0, 45.0), CursorIcon::ResizeNwSe); - assert_eq!(resize_icon(-1, -1, 90.0), CursorIcon::ResizeNeSw); + assert_eq!(resize_icon(1, 0, 0.0, false), CursorIcon::ResizeHorizontal); + assert_eq!(resize_icon(1, 0, 90.0, false), CursorIcon::ResizeVertical); + assert_eq!(resize_icon(1, 0, 45.0, false), CursorIcon::ResizeNwSe); + assert_eq!(resize_icon(-1, -1, 90.0, false), CursorIcon::ResizeNeSw); assert_eq!(normalized_angle(540.0), 180.0); assert_eq!(normalized_angle(-190.0), 170.0); assert_eq!(normalized_angle(f64::NAN), 0.0); @@ -1272,6 +1276,29 @@ mod tests { } } + /// Fill › Content-Aware Fill (#3823): ↵ on an extending frame fills the added canvas from the + /// image instead of the background colour; Background (Default) keeps the colour. + #[test] + fn commit_passes_the_fill_choice() { + for content_aware in [false, true] { + let mut app = app(SampleType::U16); + app.run("tools.setColors", json!({"foreground": "#808080", "background": "#000000"})).unwrap(); + app.run("edit.fill", json!({"contents": "foreground"})).unwrap(); + app.ui.tool_options.crop_content_aware = content_aware; + app.ui.crop_rect = Some([-10.0, -10.0, 210.0, 110.0]); + app.crop.default_frame = false; + crate::canvas::commit_crop(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (220, 120)); + let corner = st.doc.layers[0].surface().unwrap().rgba(0, 0); + if content_aware { + assert!((corner[0] - 0.5).abs() < 0.05, "filled from the grey image: {corner:?}"); + } else { + assert!(corner[0] < 0.05, "the background colour: {corner:?}"); + } + } + } + #[test] fn moving_the_frame_snaps_its_edges() { let mut app = app(SampleType::U8); @@ -1786,6 +1813,12 @@ mod tests { assert_eq!(cursor(&app, [60.0, 60.0]), Some(CursorIcon::ResizeVertical)); assert_eq!(cursor(&app, [60.0, 40.0]), Some(CursorIcon::Move)); assert_eq!(cursor(&app, [150.0, 40.0]), Some(CursorIcon::None), "outside: the drawn turn cursor"); + // View › Flip Horizontal shows the top-left corner at the top right: its arrow mirrors too. + app.ui.view.flip_horizontal = true; + assert_eq!(cursor(&app, [20.0, 20.0]), Some(CursorIcon::ResizeNeSw), "flipped view"); + assert_eq!(cursor(&app, [100.0, 20.0]), Some(CursorIcon::ResizeNwSe), "flipped view"); + assert_eq!(cursor(&app, [60.0, 60.0]), Some(CursorIcon::ResizeVertical), "flipped view"); + app.ui.view.flip_horizontal = false; app.ui.tool = Tool::Brush; assert_eq!(cursor(&app, [60.0, 40.0]), None); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0530031f93..2930889bab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,6 +104,10 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } +fn resizable_dialog(fields: &serde_json::Map) -> bool { + fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields) +} + fn show_modal( modal: egui::Modal, ctx: &egui::Context, @@ -182,14 +186,16 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let web = fields.contains_key("__web"); let web_popup_open = web && egui::Popup::is_any_open(ctx); - let modal = show_modal(modal, ctx, id, web, interactive, |ui| { + let modal = show_modal(modal, ctx, id, resizable_dialog(&fields), interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title // rule and the button row fill the width they are given, so it needs its own maximum too. let compact = d.fields.contains_key("__compact"); + // The curve editor caps its graph allocation at 300 points (#5113). + let curves = fields.get("__adjust").and_then(Value::as_str) == Some("curves"); if !compact { - ui.set_min_width(380.0); + ui.set_min_width(if curves { 300.0 } else { 380.0 }); } let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { @@ -211,6 +217,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 600.0 } else if compact { 260.0 + } else if curves { + 300.0 } else { 440.0 })); @@ -998,3 +1006,21 @@ mod tests { } } } + +#[cfg(test)] +mod preference_resize_tests { + use super::*; + + #[test] + fn preferences_can_resize_while_standard_native_dialogs_keep_their_layout() { + let prefs = json!({"__prefsui": "prefs"}); + let shortcuts = json!({"__prefsui": "shortcuts"}); + let ordinary = json!({}); + let web = json!({"__web": true}); + + assert!(resizable_dialog(prefs.as_object().unwrap())); + assert!(!resizable_dialog(shortcuts.as_object().unwrap())); + assert!(!resizable_dialog(ordinary.as_object().unwrap())); + assert!(resizable_dialog(web.as_object().unwrap())); + } +} diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index addd5fd0de..26fba5dadc 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -67,6 +67,10 @@ pub struct DirectSelection { pub segment: Option<[usize; 2]>, #[serde(skip)] pub drag: Option, + /// The history step the drag's commit joins (`coalesce`): a Curvature Pen click on a segment + /// adds the anchor it then drags, and both are one step. + #[serde(skip)] + pub coalesce: Option, } #[derive(Clone, Debug, PartialEq)] @@ -80,6 +84,8 @@ pub enum Drag { #[derive(Clone, Debug, PartialEq)] pub enum Edit { Anchors(Vec<[usize; 2]>), + /// The Curvature Pen dragging anchors: the smooth knots around them reflow. + Curvature(Vec<[usize; 2]>), /// A handle, where it was at the press, and whether it moves alone (⌥). Handle([usize; 2], Handle, Point, bool), Segment([usize; 2], f64), @@ -93,6 +99,7 @@ impl Edit { let d = [end[0] - start[0], end[1] - start[1]]; match self { Edit::Anchors(a) => ("path.moveAnchors", json!({"anchors": a, "move": d})), + Edit::Curvature(a) => ("path.moveAnchors", json!({"anchors": a, "move": d, "curvature": true})), Edit::Handle([s, k], h, from, independent) => { ("path.moveHandle", json!({"subpath": s, "knot": k, "handle": h.name(), "to": [from.x + d[0], from.y + d[1]], "independent": independent})) } @@ -107,6 +114,7 @@ impl Edit { let mut p = base.clone(); match self { Edit::Anchors(a) => edit::move_anchors(&mut p, a, d), + Edit::Curvature(a) => edit::move_anchors_curvature(&mut p, a, d), Edit::Handle(k, h, from, independent) => edit::move_handle(&mut p, *k, *h, Point::new(from.x + d[0], from.y + d[1]), *independent), Edit::Segment(k, t) => edit::bend_segment(&mut p, *k, *t, d), Edit::Convert(k) => edit::convert_point(&mut p, *k, Some(Point::new(end[0], end[1]))), @@ -130,6 +138,12 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { !mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle)) } +/// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in +/// place, as in Photoshop: a press on one of its anchors drags it, with no modifier. +fn curvature_idle(app: &PhotocraftApp, mods: Modifiers) -> bool { + app.ui.tool == Tool::CurvaturePen && !mods.command && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) +} + /// The tools that take every pointer event on the canvas, not just a press that hits a path. fn owns(tool: Tool) -> bool { matches!(tool, Tool::DirectSelection | Tool::ConvertPoint) @@ -139,6 +153,7 @@ fn owns(tool: Tool) -> bool { /// Delete Anchor Point tools show it too: they edit the same paths. pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { direct(app.ui.tool, mods) + || curvature_idle(app, mods) || app.ui.direct_selection.drag.is_some() || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) } @@ -199,6 +214,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let tool = app.ui.tool; match ev { ToolEvent::Down { x, y, .. } => { + if curvature_idle(app, mods) { + return curvature_press(app, [x, y]); + } let pen_idle = app.ui.pen.is_none(); // Pen + ⌥ is Convert Point only while no path is being drawn. let convert = converting(tool, mods, pen_idle); @@ -231,6 +249,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() { Some(Drag::Edit { target, base, edit, start, .. }) => { release(app, target, &base, &edit, start, [x, y]); + app.ui.direct_selection.coalesce = None; true } Some(Drag::Marquee { start, add, .. }) => { @@ -326,6 +345,83 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - true } +/// What the idle Curvature Pen has under document point `p` on the active paths: an anchor or a +/// segment, with the path it is on. Handles don't count. +fn curvature_target(app: &PhotocraftApp, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + active_paths(app).into_iter().find_map(|(r, path)| match edit::hit(&path, Point::new(p[0], p[1]), tol, &[]) { + Some(h @ (Hit::Anchor(_) | Hit::Segment(..))) => Some((r, path, h)), + _ => None, + }) +} + +/// What a Curvature Pen press at `p` would do to an existing point, for its cursor: the anchor +/// under it (idle: one of the active path's; drawing: one of the path being drawn), or the active +/// path's segment it would add an anchor to. +pub fn curvature_hover(app: &PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Option { + if app.ui.tool != Tool::CurvaturePen || mods.command { + return None; + } + if !curvature_idle(app, mods) { + return crate::vector_ui::curvature_anchor_at(app, p[0], p[1]).map(|i| Hit::Anchor([0, i])); + } + curvature_target(app, p).map(|(.., h)| h) +} + +/// The idle Curvature Pen's double-click on an anchor of the active path: smooth ↔ corner, and +/// the subpath reflows (one `path.convertPoint` with `curvature`). +pub(crate) fn curvature_toggle_active(app: &mut PhotocraftApp, p: [f64; 2]) { + let Some((target, _, Hit::Anchor([s, k]))) = curvature_target(app, p) else { return }; + let mut params = json!({"subpath": s, "knot": k, "curvature": true}); + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + o.extend(tp.clone()); + } + if let Err(e) = app.run("path.convertPoint", params) { + app.ui.status = e; + app.ui.status_error = true; + } + app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![[s, k]], ..Default::default() }; +} + +/// Curvature Pen press with no path being drawn, on the active path: an anchor is grabbed and +/// dragged; a segment gets a new anchor there (`path.addAnchor`), dragged the same way. Either +/// way the curve reflows smoothly through the moved anchor, as in Photoshop (`path.moveAnchors` +/// with `curvature` on release), and an added anchor and its drag are one history step. False +/// when the press hits neither, so it starts a new path. +fn curvature_press(app: &mut PhotocraftApp, p: [f64; 2]) -> bool { + let Some((target, mut base, hit)) = curvature_target(app, p) else { return false }; + let mut coalesce = None; + let k = match hit { + Hit::Anchor(k) => k, + Hit::Segment([s, k], t) => { + let key = format!("curvature-add-{}", app.ui.alloc_id()); + let mut params = json!({"subpath": s, "knot": k, "t": t.clamp(0.01, 0.99), "coalesce": key}); + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + o.extend(tp.clone()); + } + let added = match app.run("path.addAnchor", params) { + Ok(v) => v, + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + return true; + } + }; + let n = |f: &str| added["anchor"][f].as_u64().and_then(|v| usize::try_from(v).ok()); + let (Some(s), Some(k), Some(path)) = (n("subpath"), n("knot"), active_paths(app).into_iter().find(|(r, _)| *r == target).map(|(_, p)| p)) else { + return true; + }; + base = path; + coalesce = Some(key); + [s, k] + } + Hit::Handle(..) => return false, + }; + app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![k], coalesce, ..Default::default() }; + app.ui.direct_selection.drag = Some(Drag::Edit { target, base, edit: Edit::Curvature(vec![k]), start: p, end: p, preview: None }); + true +} + /// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except /// Pen + ⌥ on an anchor, which makes it a corner. The Pen path being drawn takes the edit directly. fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, start: [f64; 2], end: [f64; 2]) { @@ -352,6 +448,9 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) { p.extend(t.clone()); } + if let (Some(p), Some(key)) = (params.as_object_mut(), app.ui.direct_selection.coalesce.take()) { + p.insert("coalesce".into(), json!(key)); + } if let Err(e) = app.run(id, params) { app.ui.status = e; app.ui.status_error = true; @@ -418,7 +517,15 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// the marquee. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, paths: bool) { let ds = &app.ui.direct_selection; - for (r, path) in if paths { candidates(app) } else { Vec::new() } { + // The idle Curvature Pen shows the anchors of the path it edits, not every shape's. + let shown = if !paths { + Vec::new() + } else if curvature_idle(app, painter.ctx().input(|i| i.modifiers)) { + active_paths(app) + } else { + candidates(app) + }; + for (r, path) in shown { let path = match &ds.drag { Some(Drag::Edit { target, preview: Some(p), .. }) if *target == r => p.clone(), _ => path, diff --git a/crates/ui-egui/src/direct_select_tests.rs b/crates/ui-egui/src/direct_select_tests.rs index c6b02c79b9..0f3a5ba990 100644 --- a/crates/ui-egui/src/direct_select_tests.rs +++ b/crates/ui-egui/src/direct_select_tests.rs @@ -325,3 +325,173 @@ fn arrow_keys_nudge_the_selected_anchors() { assert!(app.session.undo() && app.session.undo()); assert_eq!(work(&app), before); } + +#[test] +fn the_curvature_pen_drags_an_anchor_of_the_active_path_without_a_modifier() { + // As in Photoshop: with no path being drawn, pressing an anchor and dragging moves it. + let mut app = app(Tool::CurvaturePen); + with_path(&mut app); + assert!(super::shows(&app, Modifiers::NONE), "the Curvature Pen shows the active path's anchors"); + let (before, n) = (work(&app), steps(&app)); + drag(&mut app, [40.5, 120.5], [50.5, 130.5], Modifiers::NONE); + assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0)); + assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "only the dragged anchor moved"); + assert_eq!(work(&app).subpaths.len(), 1, "no new path was started"); + assert!(app.ui.pen.is_none(), "nor is one being drawn"); + assert_eq!(steps(&app), n + 1, "one history step for the whole drag"); + assert!(app.session.undo()); + assert_eq!(work(&app), before); +} + +#[test] +fn the_curvature_pen_off_an_anchor_still_draws_and_edits_its_own_path() { + let mut app = app(Tool::CurvaturePen); + with_path(&mut app); + let before = work(&app); + // A press on empty canvas starts a new path, as before (a segment would get an anchor). + click(&mut app, [80.0, 170.0], Modifiers::NONE); + click(&mut app, [170.0, 170.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + assert!(!super::shows(&app, Modifiers::NONE), "while drawing, the Curvature Pen shows its own path"); + // While drawing, a press on an anchor of the path being drawn moves that one, not the work path's. + drag(&mut app, [170.0, 170.0], [180.0, 160.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().and_then(|p| p.knots.get(1)).map(|k| k[0]), Some([180.0, 160.0])); + assert_eq!(work(&app), before); +} + +#[test] +fn a_closed_curvature_pen_path_stays_editable_with_the_curvature_pen() { + let mut app = app(Tool::CurvaturePen); + app.ui.tool_options.vector_mode = "path".into(); + for p in [[40.0, 40.0], [160.0, 40.0], [100.0, 160.0], [40.0, 40.0]] { + click(&mut app, p, Modifiers::NONE); + } + assert!(app.ui.pen.is_none(), "clicking the first anchor closed the path"); + assert!(work(&app).subpaths[0].closed); + let n = steps(&app); + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 160.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 180.0, pressure: 1.0 }, Modifiers::NONE); + let Some(Drag::Edit { preview: Some(shown), .. }) = app.ui.direct_selection.drag.clone() else { panic!("an anchor drag with a preview") }; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 180.0 }, Modifiers::NONE); + assert_eq!(work(&app), shown, "the preview is what the drag commits"); + assert_eq!((work(&app).subpaths.len(), steps(&app)), (1, n + 1)); + // The whole loop reflows through the moved anchor, as the Curvature Pen drew it. + let pts = [[40.0, 40.0], [160.0, 40.0], [100.0, 180.0]]; + let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 3], true); + for (k, w) in work(&app).subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } +} + +/// A Pen-drawn work path: a closed square of corners (40, 40)–(160, 160). +fn with_corner_square(app: &mut PhotocraftApp) { + 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(); +} + +#[test] +fn the_curvature_pen_adds_an_anchor_on_a_segment_and_drags_it_in_one_step() { + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + let n = steps(&app); + // A press on the top edge adds an anchor there; the drag pulls it up. + drag(&mut app, [100.0, 40.0], [100.0, 10.0], Modifiers::NONE); + let sp = &work(&app).subpaths[0]; + assert_eq!(sp.knots.len(), 5, "one anchor added"); + assert_eq!(sp.knots[1].anchor, Point::new(100.0, 10.0)); + assert!(app.ui.pen.is_none(), "no new path was started"); + assert_eq!(steps(&app), n + 1, "adding and dragging the anchor is one history step"); + assert!(app.session.undo()); + assert_eq!(work(&app).subpaths[0].knots.len(), 4); + // A click on a segment just adds the anchor. + click(&mut app, [160.0, 100.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 5); +} + +#[test] +fn a_curvature_pen_double_click_rounds_a_pen_corner_and_back() { + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + let before = work(&app); + crate::vector_ui::curvature_toggle(&mut app, 160.0, 40.0); + let k = work(&app).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "the corner turned smooth: {k:?}"); + crate::vector_ui::curvature_toggle(&mut app, 160.0, 40.0); + assert_eq!(work(&app), before); + // Off the path it does nothing (and starts nothing). + crate::vector_ui::curvature_toggle(&mut app, 100.0, 100.0); + assert_eq!((work(&app), app.ui.pen.is_none()), (before, true)); +} + +#[test] +fn the_curvature_pen_cursor_shows_what_a_press_does() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + for (at, want) in [([100.0, 40.0], Some(Badge::Add)), ([160.0, 40.0], Some(Badge::Move)), ([100.0, 100.0], None)] { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), want, "at {at:?}"); + } + // ⌘/Ctrl is Direct Selection: no Curvature Pen badge. + app.hover_doc = Some([160.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::COMMAND), None); + // While drawing, its own anchors take the move badge and the active path's segments none. + click(&mut app, [20.0, 180.0], Modifiers::NONE); + click(&mut app, [60.0, 190.0], Modifiers::NONE); + app.hover_doc = Some([60.0, 190.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), Some(Badge::Move)); + app.hover_doc = Some([100.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), None); +} + +/// The real canvas: a Curvature Pen double-click on a Pen corner of the active path rounds it. +#[test] +fn a_curvature_pen_double_click_through_the_canvas_rounds_a_corner() { + use egui::{PointerButton, vec2}; + use egui_kittest::Harness; + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + app.ui.extras.rulers = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.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 v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [100.0, 100.0]; + v.fit_pending = false; + h.run_steps(2); + let p = { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = crate::canvas::ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: false, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(160.0, 40.0) + }; + for _ in 0..2 { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + for down in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(1); + let k = work(h.state()).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor, "the double-clicked corner is smooth: {k:?}"); + assert!(h.state().ui.pen.is_none(), "and no path was started"); +} diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 23248cd9ae..58519899d0 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -192,7 +192,8 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { return false; } match saved { - Ok(_) => !app.save_options_open() && !app.saving(), + // A Save Document script may have edited the document after it was written: still dirty. + Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -395,6 +396,32 @@ mod tests { app.session.documents()[i].doc.id } + #[test] + fn a_save_document_script_that_edits_the_document_keeps_the_prompt_up() { + use photocraft_engine::automate_cmds::ScriptBinding; + let mut app = app_with_docs(1); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + app.session.edit_prefs(|prefs| { + prefs.script_events.enabled = true; + prefs.script_events.bindings = vec![ScriptBinding { + event: "saveDocument".into(), + script: None, + steps: Some(json!([["layer.new.layer", {"name": "From the script"}]])), + name: "Binding".into(), + }]; + }); + let doc = doc_id(&app, 0); + assert!(!save(&mut app, &ctx, doc), "the script's layer was never written"); + assert!(app.session.documents()[0].is_dirty()); + // Without the script the same save releases the prompt. + app.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + assert!(save(&mut app, &ctx, doc)); + } + #[test] fn clean_documents_close_without_asking() { let mut app = app_with_docs(2); diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 9f0966b5c3..1f12b129f1 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -27,6 +27,8 @@ pub struct Distort { pub vanishing: Option, /// Filter Gallery dialog (gallery_ui). pub gallery: Option, + /// The document the open sessions belong to, noted by [`drop_foreign`] on the first frame they are open. + owner: Option, } impl Distort { @@ -236,8 +238,27 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { false } +/// Drops the open sessions once the active document is not the one they were started on (closed, or another one +/// opened or switched to). Without this, Puppet Warp's preview of the old document replaced the new document's canvas (#5092). +pub fn drop_foreign(app: &mut PhotocraftApp) { + let current = app.session.active().map(|s| s.doc.id); + if !app.distort.active() && app.distort.gallery.is_none() { + app.distort.owner = None; + } else if app.distort.owner.is_none() { + app.distort.owner = current; + } else if app.distort.owner != current { + app.distort.liquify = None; + app.distort.puppet = None; + app.distort.perspective = None; + app.distort.vanishing = None; + app.distort.gallery = None; + app.distort.owner = None; + } +} + /// Full-window dialogs (Liquify, Filter Gallery). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + drop_foreign(app); if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 8c10b8c442..fbdbc9a955 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -100,7 +100,7 @@ fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_pr // Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size. let extent = f64::from(current); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(extent, extent)); - let r = widgets::value_field(ui, &mut v, 0.01..=300_000.0, u.unit.suffix(), 74.0); + let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -318,6 +318,30 @@ mod tests { assert_eq!(canvas_resize_params((2400, 1500), Some(2400.2), None, false), None); } + /// #5661: a 1 x 1 px document at 300 ppi is 0.0033 in wide. With the rulers in inches the + /// Width/Height fields clamped that to 0.01 in (3 px) and resized the document unasked. + #[test] + fn tiny_document_in_inches_is_not_resized_by_showing_it() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1, "height": 1, "resolution": 300, "background": "white"})).unwrap(); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = photocraft_engine::prefs::Unit::Inches); + let undo = app.session.active().unwrap().history.entries().len(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (1, 1)); + assert_eq!(st.history.entries().len(), undo, "no history step"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0aab2769ea..deaea4e783 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -38,11 +38,13 @@ pub enum Group { History, /// Layers | Channels | Paths. Layers, + /// Brushes presets. + Brushes, } impl Group { /// Photoshop Essentials order, top to bottom. - pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers]; + pub const ALL: [Group; 7] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers, Group::Brushes]; pub fn key(self) -> &'static str { match self { @@ -52,6 +54,7 @@ impl Group { Group::Navigator => "navigator", Group::History => "history", Group::Layers => "layers", + Group::Brushes => "brushes", } } @@ -65,6 +68,7 @@ impl Group { Group::Navigator => 210.0, Group::History => 200.0, Group::Layers => 320.0, + Group::Brushes => 200.0, } } @@ -78,6 +82,7 @@ impl Group { Group::Navigator => 140.0, Group::History => 130.0, Group::Layers => 200.0, + Group::Brushes => 150.0, } } @@ -86,6 +91,7 @@ impl Group { pub fn preferred_fill(self) -> f32 { match self { Group::Layers => 500.0, + Group::Brushes => 100.0, g => g.min_height(), } } @@ -94,6 +100,7 @@ impl Group { pub fn min_height(self) -> f32 { match self { Group::Layers => 140.0, + Group::Brushes => 100.0, _ => 80.0, } } @@ -108,6 +115,7 @@ impl Group { Group::Navigator => &["Navigator", "Histogram", "Info"], Group::History => &["History", "Actions", "Layer Comps"], Group::Layers => &["Layers", "Channels", "Paths"], + Group::Brushes => &["Brushes"], } } @@ -125,6 +133,7 @@ impl Group { Group::Navigator => &mut tabs.navigator, Group::History => &mut tabs.history, Group::Layers => &mut tabs.layers, + Group::Brushes => &mut tabs.brushes, } } @@ -141,6 +150,7 @@ impl Group { Group::Navigator => panels.navigator, Group::History => panels.history, Group::Layers => panels.layers, + Group::Brushes => panels.brushes, } } @@ -152,6 +162,7 @@ impl Group { Group::Navigator => &mut panels.navigator, Group::History => &mut panels.history, Group::Layers => &mut panels.layers, + Group::Brushes => &mut panels.brushes, } } } diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index beb6b913de..f0a64562c4 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() { #[test] fn bad_stored_values_are_sanitised() { let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() }; - assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(l.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] { l.heights.insert(Group::Color, bad); let h = l.height(Group::Color); @@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() { } // Unknown fields, wrong types and old UI state all load. let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap(); - assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(back.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap(); ui.as_object_mut().unwrap().remove("dock"); let ui: crate::state::UiState = serde_json::from_value(ui).unwrap(); @@ -308,12 +308,13 @@ fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() let (app, _, _) = app_with_layers(); let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); let ctx = h.ctx.clone(); - let default = last_rects(&h.ctx); h.state_mut().ui.dock.heights.insert(Group::Properties, 120.0); h.state_mut().ui.dock.set_collapsed(Group::Color, true); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Tall Layers"})).unwrap(); let mine = h.state().ui.dock.clone(); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.essentials", json!({})).unwrap(); + h.run_steps(3); + let default = last_rects(&h.ctx); assert_eq!(h.state().ui.dock, DockLayout::default()); h.state_mut().ui.dock.heights.insert(Group::Color, 300.0); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap(); @@ -802,7 +803,7 @@ fn window_character_opens_its_own_group_and_keeps_properties() { let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() }; assert_eq!(old.order(), Group::ALL.to_vec()); let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() }; - assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(moved.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); } type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option, Rect)>; diff --git a/crates/ui-egui/src/empty_workspace_tests.rs b/crates/ui-egui/src/empty_workspace_tests.rs new file mode 100644 index 0000000000..f7473dbccc --- /dev/null +++ b/crates/ui-egui/src/empty_workspace_tests.rs @@ -0,0 +1,89 @@ +//! With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +//! (#5367): with Auto Show the Home Screen on (the Home screen's blank background) and off (the +//! plain workspace). A single click does nothing, and the Home screen's own buttons keep theirs. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::file_dialog::{FileDialogReply, FileDialogRequest}; +use crate::{PhotocraftApp, Services}; + +type Requests = Rc>>; + +fn harness(home: bool) -> (Harness<'static, PhotocraftApp>, Requests) { + let requests: Requests = Default::default(); + let held = requests.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _parent, reply| held.borrow_mut().push((request, reply)))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = home); + 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(); + // Fonts set up after the first frame only apply from the next one. + 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.poll_file_dialog(&ctx, None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, requests) +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +/// `count` clicks two frames apart at `at`, after letting the clock move past earlier clicks. +fn clicks(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, count: usize) { + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..count { + button(h, at, true); + h.step(); + button(h, at, false); + h.step(); + } + h.run_steps(3); +} + +fn opens(requests: &Requests) -> usize { + requests.borrow().iter().filter(|(r, _)| matches!(r, FileDialogRequest::Open { multiple: true, .. })).count() +} + +#[test] +fn double_clicking_the_empty_workspace_shows_file_open() { + for home in [false, true] { + let (mut h, requests) = harness(home); + assert!(h.state().session.documents().is_empty()); + // Well away from the Home screen's centred card. + let blank = pos2(40.0, 760.0); + clicks(&mut h, blank, 1); + assert_eq!(opens(&requests), 0, "home {home}: a single click opens nothing"); + clicks(&mut h, blank, 2); + assert_eq!(opens(&requests), 1, "home {home}: a double-click shows File › Open"); + } +} + +#[test] +fn the_home_screens_own_widgets_keep_their_double_clicks() { + let (mut h, requests) = harness(true); + // The subtitle is selectable text: a double-click there selects a word, not File › Open. + let at = h.get_by_label_contains("Create a new document or open an existing file").rect().center(); + clicks(&mut h, at, 2); + assert_eq!(opens(&requests), 0, "the label took the double-click"); + // The New document button opens the New Document dialog, not File › Open. + let at = h.get_by_label_contains("New document").rect().center(); + clicks(&mut h, at, 1); + assert_eq!(opens(&requests), 0); + assert_eq!(h.state().ui.dialogs.len(), 1, "the New Document dialog"); +} diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index a322ee108d..9367045aff 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -38,17 +38,35 @@ fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { f.insert(key.into(), json!(v)); } } - if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "all"].contains(v)) { + if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "exif", "all"].contains(v)) { f.insert("metadata".into(), json!(v)); } if let Some(v) = saved.get("tgaBits").and_then(Value::as_str).filter(|v| ["auto", "24", "32"].contains(v)) { f.insert("tgaBits".into(), json!(v)); } + if let Some(v) = saved.get("sizing").and_then(Value::as_str).filter(|v| ["scale", "pixels"].contains(v)) { + f.insert("sizing".into(), json!(v)); + } + if let Some(v) = saved.get("lockAspect").and_then(Value::as_bool) { + f.insert("lockAspect".into(), json!(v)); + } + if let Some(v) = saved.get("embedProfile").and_then(Value::as_bool) { + f.insert("embedProfile".into(), json!(v)); + } + if let Some(v) = saved.get("profile").and_then(Value::as_str).filter(|v| ["document", "srgb"].contains(v)) { + f.insert("profile".into(), json!(v)); + } + for key in ["width", "height"] { + if let Some(v) = saved.get(key).and_then(Value::as_f64).filter(|v| v.is_finite() && (1.0..=MAX_PX).contains(v)) { + f.insert(key.into(), json!(v.round())); + } + } } /// Record the settings for the next Export As (see [`seed_remembered`]). fn remember(app: &mut PhotocraftApp, f: &Map) { - const KEYS: [&str; 7] = ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits"]; + const KEYS: [&str; 13] = + ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits", "sizing", "width", "height", "lockAspect", "profile", "embedProfile"]; let saved: Map = KEYS.iter().filter_map(|k| f.get(*k).map(|v| ((*k).to_string(), v.clone()))).collect(); app.session.prefs.edit(|p| { p.dialogs.insert(COMMAND.into(), Value::Object(saved)); @@ -67,9 +85,24 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("scale".into(), json!(100)); f.insert("metadata".into(), json!("none")); f.insert("tgaBits".into(), json!("auto")); + f.insert("sizing".into(), json!("scale")); + f.insert("lockAspect".into(), json!(true)); + f.insert("profile".into(), json!("document")); + f.insert("embedProfile".into(), json!(true)); + f.insert("width".into(), json!(st.doc.size.width)); + f.insert("height".into(), json!(st.doc.size.height)); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); seed_remembered(app, &mut f); + let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height"); + if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 { + f.insert("scale".into(), json!(size.scale)); + } + f.insert("sizing".into(), json!("scale")); + } Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -210,30 +243,25 @@ fn n(f: &Map, k: &str, d: f64) -> f64 { /// The document as exported: scaled (engine Image Size, bicubic) and flattened over white when /// transparency is off or the format has no alpha. fn export_document(doc: &Document, f: &Map, max_side: Option) -> Result { - let mut s = photocraft_engine::Session::new(); - s.add_document(doc.clone(), None); - let mut scale = n(f, "scale", 100.0) / 100.0; - if let Some(m) = max_side { - let long = doc.size.width.max(doc.size.height) as f64 * scale; - if long > m as f64 { - scale *= m as f64 / long; - } + let mut session = photocraft_engine::Session::new(); + session.add_document(doc.clone(), None); + if s(f, "profile") == "srgb" && doc.icc_profile.is_some() { + session.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"})).map_err(|e| e.to_string())?; } - // Both dimensions are sent: a scale can leave a 1 px width unchanged while halving the - // height, and the engine would otherwise keep the aspect ratio from the width alone. - let (w, h) = scaled_size(doc.size.width as f64, doc.size.height as f64, scale); + let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, max_side)?; if s_fmt(f) == "ico" && (w > 256.0 || h > 256.0) && max_side.is_none() { return Err("ICO export is limited to 256 × 256 pixels; reduce the export scale".into()); } if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 { - s.execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) + session + .execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) .map_err(|e| e.to_string())?; } let fmt = s_fmt(f); if !f.get("transparency").and_then(Value::as_bool).unwrap_or(true) || fmt == "jpg" { - s.execute("layer.flattenImage", json!({})).map_err(|e| e.to_string())?; + session.execute("layer.flattenImage", json!({})).map_err(|e| e.to_string())?; } - s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into()) + session.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into()) } /// The exported pixel size: each side scaled and rounded on its own, never below 1 px. The @@ -242,6 +270,84 @@ fn scaled_size(w: f64, h: f64, scale: f64) -> (f64, f64) { ((w * scale).round().max(1.0), (h * scale).round().max(1.0)) } +/// Largest side Export As will ask Image Size for. The same cap `image.imageSize` enforces. +const MAX_PX: f64 = 300_000.0; + +/// Pixel size, percent scale and whether the two sides stay linked. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ExportSize { + width: f64, + height: f64, + scale: f64, +} + +fn finite_px(v: f64) -> Result { + if !v.is_finite() || !(1.0..=MAX_PX).contains(&v) { + return Err(format!("export size must be within 1..={MAX_PX} pixels")); + } + Ok(v.round()) +} + +/// Percent scale applied to both sides. `scale` is a percent in 1..=1000. +fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result { + if !scale.is_finite() || !(1.0..=1000.0).contains(&scale) { + return Err("scale must be within 1..=1000 percent".into()); + } + let (width, height) = scaled_size(src_w.max(1.0), src_h.max(1.0), scale / 100.0); + if width > MAX_PX || height > MAX_PX { + return Err(format!("export size must be within 1..={MAX_PX} pixels")); + } + Ok(ExportSize { width, height, scale }) +} + +/// An edited side. With `lock`, the other side keeps the source aspect; otherwise it stays. +fn resize_from_side(src_w: f64, src_h: f64, width: f64, height: f64, lock: bool, edited_width: bool) -> Result { + let src_w = src_w.max(1.0); + let src_h = src_h.max(1.0); + let (width, height) = if edited_width { + let width = finite_px(width)?; + let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? }; + (width, height) + } else { + let height = finite_px(height)?; + let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? }; + (width, height) + }; + let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 }; + Ok(ExportSize { width, height, scale }) +} + +/// The pixels `export_document` resizes to. Explicit width and height win; otherwise the percent +/// scale does, and a preview limit shrinks the long side before rounding (the same reduction the +/// scale path used before pixel fields existed). +fn export_pixels(src_w: f64, src_h: f64, f: &Map, max_side: Option) -> Result<(f64, f64), String> { + // Exact pixels apply only in pixel mode. Scale mode keeps the percent, so each layer in a + // multi-layer export is scaled from its own size rather than forced to the preview's pixels. + let explicit = if s(f, "sizing") == "pixels" { f.get("width").and_then(Value::as_f64).zip(f.get("height").and_then(Value::as_f64)) } else { None }; + let (mut w, mut h) = if let Some((w, h)) = explicit { + (finite_px(w)?, finite_px(h)?) + } else { + let mut scale = n(f, "scale", 100.0) / 100.0; + if !scale.is_finite() || scale <= 0.0 { + return Err("export scale must be a positive number".into()); + } + if let Some(m) = max_side { + let long = src_w.max(src_h) * scale; + if long > f64::from(m) { + scale *= f64::from(m) / long; + } + } + return Ok(scaled_size(src_w, src_h, scale)); + }; + if let Some(m) = max_side { + let long = w.max(h); + if long > f64::from(m) && long > 0.0 { + (w, h) = scaled_size(w, h, f64::from(m) / long); + } + } + Ok((w, h)) +} + /// Export As already supports lossy WebP; reuse the Quick Export defaults when /// choosing that format so both flows expose the same quality and lossless options. fn set_format_defaults(f: &mut Map, fmt: &str, prefs: &photocraft_engine::prefs::Export) { @@ -276,12 +382,17 @@ fn tga_bits(f: &Map) -> Option { fn settings(f: &Map) -> ExportSettings { let fmt = s_fmt(f); let quality = n(f, "quality", 85.0).clamp(1.0, 100.0) as u8; + let metadata = s(f, "metadata"); ExportSettings { jpeg_quality: (fmt == "jpg").then_some(quality), webp_lossless: fmt != "webp" || lossless(f), webp_quality: (fmt == "webp" && !lossless(f)).then_some(quality), - xmp_all: s(f, "metadata") == "all", + xmp_all: metadata == "all", tga_bits: if fmt == "tga" { tga_bits(f) } else { None }, + embed_icc: f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true), + // Metadata: None drops EXIF and resolution as well as XMP. A missing key keeps them, + // which is what Export As wrote before the choice existed. + embed_metadata: metadata != "none", ..Default::default() } } @@ -293,8 +404,13 @@ fn preview_proxy(doc: &Document, f: &Map) -> Result) -> u64 { - let scale = n(f, "scale", 100.0) / 100.0; - let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale; + let full = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, + _ => { + let scale = n(f, "scale", 100.0) / 100.0; + doc.size.width as f64 * scale * doc.size.height as f64 * scale + } + }; let small = (proxy.size.width as f64 * proxy.size.height as f64).max(1.0); (bytes as f64 * full / small) as u64 } @@ -406,31 +522,193 @@ fn poll_or_start_preview( /// The native exporter uses this same IO encoder. Keep the entire estimate off the UI thread. #[cfg(not(target_arch = "wasm32"))] fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Option { - let settings = settings(f); - let mut options = photocraft_io::ExportOptions::default(); - if let Some(quality) = settings.jpeg_quality { - options.encode.jpeg_quality = quality; - } - options.encode.webp_lossless = settings.webp_lossless; - if let Some(quality) = settings.webp_quality { - options.encode.webp_quality = quality; - } - options.tiff_layers = settings.tiff_layers; - options.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - options.tga_bits = settings.tga_bits; - options.image_previews = settings.image_previews; - options.psd_maximize_compatibility = settings.psd_maximize_compatibility; - let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &options).ok()?.bytes; + let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes; Some(scaled_estimate(bytes.len(), doc, proxy, f)) } +fn write_size(f: &mut Map, size: ExportSize, sizing: &str) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + f.insert("scale".into(), json!(size.scale)); + f.insert("sizing".into(), json!(sizing)); +} + +/// Scale, width and height. A scale edit sets both sides; a side edit sets exact pixels and, when +/// the link is on, the other side. The widget is compared with the value it was given so a field +/// that only clamps an out-of-range scale does not overwrite an exact pixel size. +fn size_fields(ui: &mut egui::Ui, f: &mut Map, src_w: f64, src_h: f64) { + let t = Tokens::get(ui.ctx()); + let stored = n(f, "scale", 100.0); + let passed = stored.clamp(1.0, 1000.0).round(); + let mut sc = passed as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0); + }); + let sc_r = f64::from(sc).round().clamp(1.0, 1000.0); + if (sc_r - passed).abs() > 0.01 + && let Ok(size) = resize_from_scale(src_w, src_h, sc_r) + { + write_size(f, size, "scale"); + } + let lock = f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true); + let stored_w = n(f, "width", src_w.max(1.0)); + let mut width = stored_w.clamp(1.0, MAX_PX) as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Width")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut width, 1.0..=MAX_PX as f32, "px", 70.0); + }); + let width_r = f64::from(width).round(); + if (width_r - stored_w.round()).abs() > 0.01 + && let Ok(size) = resize_from_side(src_w, src_h, width_r, n(f, "height", src_h.max(1.0)), lock, true) + { + write_size(f, size, "pixels"); + } + let stored_h = n(f, "height", src_h.max(1.0)); + let mut height = stored_h.clamp(1.0, MAX_PX) as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Height")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut height, 1.0..=MAX_PX as f32, "px", 70.0); + }); + let height_r = f64::from(height).round(); + if (height_r - stored_h.round()).abs() > 0.01 + && let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), height_r, lock, false) + { + write_size(f, size, "pixels"); + } + let mut linked = lock; + if crate::widgets::checkbox(ui, &mut linked, tl!("Constrain proportions")).changed() { + f.insert("lockAspect".into(), json!(linked)); + if linked && let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true) { + write_size(f, size, "pixels"); + } + } +} + +fn apply_preset(f: &mut Map, settings: &photocraft_engine::export_preset_cmds::ExportPresetSettings, src_w: f64, src_h: f64) { + f.insert("format".into(), json!(settings.format)); + f.insert("quality".into(), json!(settings.quality)); + f.insert("lossless".into(), json!(settings.lossless)); + f.insert("transparency".into(), json!(settings.transparency)); + f.insert("scale".into(), json!(settings.scale)); + f.insert("lockAspect".into(), json!(settings.lock_aspect)); + f.insert("sizing".into(), json!(settings.sizing)); + f.insert("metadata".into(), json!(settings.metadata)); + f.insert("profile".into(), json!(settings.profile)); + f.insert("embedProfile".into(), json!(settings.embed_profile)); + f.insert("tgaBits".into(), json!(settings.tga_bits)); + if settings.sizing == "pixels" { + f.insert("width".into(), json!(settings.width)); + f.insert("height".into(), json!(settings.height)); + } else if let Ok(size) = resize_from_scale(src_w, src_h, settings.scale) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + f.insert("scale".into(), json!(size.scale)); + } +} + +fn preset_settings(f: &Map) -> Value { + let metadata = s(f, "metadata"); + let metadata = if ["none", "exif", "all"].contains(&metadata.as_str()) { metadata } else { "none".into() }; + let bits = s(f, "tgaBits"); + let bits = if ["auto", "24", "32"].contains(&bits.as_str()) { bits } else { "auto".into() }; + let scale = n(f, "scale", 100.0); + let scale = if scale.is_finite() { scale.clamp(1.0, 1000.0) } else { 100.0 }; + json!({ + "format": s_fmt(f), + "quality": n(f, "quality", 85.0).clamp(1.0, 100.0).round(), + "lossless": lossless(f), + "transparency": f.get("transparency").and_then(Value::as_bool).unwrap_or(true), + "scale": scale, + "width": n(f, "width", 1.0).clamp(1.0, MAX_PX).round(), + "height": n(f, "height", 1.0).clamp(1.0, MAX_PX).round(), + "lockAspect": f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true), + "sizing": if s(f, "sizing") == "pixels" { "pixels" } else { "scale" }, + "metadata": metadata, + "profile": if s(f, "profile") == "srgb" { "srgb" } else { "document" }, + "embedProfile": f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true), + "tgaBits": bits, + }) +} + +fn preset_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, src_w: f64, src_h: f64) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + let names: Vec = app.session.presets.export_presets.iter().map(|p| p.name.clone()).collect(); + if !names.is_empty() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); + let mut selected = s(f, "__preset"); + let mut opts = Vec::with_capacity(names.len() + 1); + opts.push(("".to_string(), "—")); + for name in &names { + opts.push((name.clone(), name.as_str())); + } + if crate::widgets::dropdown(ui, "export-preset", &mut selected, &opts, 130.0) { + f.insert("__preset".into(), json!(selected)); + if let Some(preset) = app.session.presets.export_presets.iter().find(|p| p.name == s(f, "__preset")).cloned() { + apply_preset(f, &preset.settings, src_w, src_h); + } + } + }); + if !s(f, "__preset").is_empty() && crate::widgets::secondary_button(ui, tl!("Delete"), 84.0).clicked() { + let name = s(f, "__preset"); + match app.run("file.export.presets.delete", json!({"name": name})) { + Ok(_) => { + f.remove("__preset"); + f.remove("__presetError"); + } + Err(e) => { + f.insert("__presetError".into(), json!(e)); + } + } + } + } + if f.get("__savingPreset").and_then(Value::as_bool) != Some(true) { + if crate::widgets::secondary_button(ui, tl!("Save Preset…"), 160.0).clicked() { + f.insert("__savingPreset".into(), json!(true)); + f.remove("__presetError"); + } + } else { + let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let label = ui.label(tl!("Preset Name")); + let mut name = s(f, "__presetName"); + ui.add(egui::TextEdit::singleline(&mut name).id_salt("export-preset-name").desired_width(160.0).char_limit(255)).labelled_by(label.id); + f.insert("__presetName".into(), json!(name)); + ui.horizontal(|ui| { + if crate::widgets::secondary_button(ui, tl!("Save"), 70.0).clicked() || enter { + match app.run("file.export.presets.save", json!({"name": name, "settings": preset_settings(f)})) { + Ok(v) => { + if let Some(saved) = v.get("preset").and_then(|p| p.get("name")).and_then(Value::as_str) { + f.insert("__preset".into(), json!(saved)); + } + f.remove("__savingPreset"); + f.remove("__presetName"); + f.remove("__presetError"); + } + Err(e) => { + f.insert("__presetError".into(), json!(e)); + } + } + } + if crate::widgets::secondary_button(ui, tl!("Cancel"), 70.0).clicked() { + f.remove("__savingPreset"); + f.remove("__presetError"); + } + }); + } + if let Some(error) = f.get("__presetError").and_then(Value::as_str) { + ui.add(egui::Label::new(error).wrap()); + } +} + pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; ui.horizontal_top(|ui| { // Left: settings. ui.vertical(|ui| { - ui.set_width(220.0); + ui.set_width(248.0); ui.label(egui::RichText::new(tl!("File Settings")).font(crate::theme::semibold(12.0)).color(t.text)); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Format")).color(t.text_dim)); @@ -470,25 +748,35 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = vec![("none".into(), tl!("None")), ("all".into(), tl!("All"))]; + let opts: Vec<(String, &str)> = vec![("none".into(), tl!("None")), ("exif".into(), tl!("EXIF")), ("all".into(), tl!("All"))]; if crate::widgets::dropdown(ui, "export-metadata", &mut m, &opts, 130.0) { f.insert("metadata".into(), json!(m)); } }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Color Profile")).color(t.text_dim)); + let mut profile = s(f, "profile"); + if profile != "srgb" { + profile = "document".into(); + } + let opts: Vec<(String, &str)> = vec![("document".into(), tl!("Document Profile")), ("srgb".into(), tl!("Convert to sRGB"))]; + if crate::widgets::dropdown(ui, "export-profile", &mut profile, &opts, 130.0) { + f.insert("profile".into(), json!(profile)); + } + }); + let mut embed = f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true); + crate::widgets::checkbox(ui, &mut embed, tl!("Embed Color Profile")); + f.insert("embedProfile".into(), json!(embed)); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); - let mut sc = n(f, "scale", 100.0) as f32; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim)); - crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0); - }); - f.insert("scale".into(), json!(sc.round())); let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)), n(f, "__h", f64::from(doc.size.height))); - let (w, h) = scaled_size(bw, bh, f64::from(sc) / 100.0); + size_fields(ui, f, bw, bh); + let (w, h) = (n(f, "width", bw), n(f, "height", bh)); ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text_dim).size(11.5)); if fmt == "ico" && (w > 256.0 || h > 256.0) { ui.label(egui::RichText::new(tl!("ICO is limited to 256 × 256 pixels")).color(t.warning)); } + preset_fields(app, ui, f, bw, bh); }); ui.add_space(12.0); // Right: preview + estimated size. @@ -496,14 +784,19 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = ui.data(|d| d.get_temp(key)); let preview = match cached.filter(|cached| cached.signature == sig) { @@ -901,4 +1194,120 @@ mod tests { f.insert("format".into(), json!("png")); assert_eq!(settings(&f).tga_bits, None, "only TGA has bits per pixel"); } + + #[test] + fn exact_pixels_lock_aspect_and_leave_the_source() { + let linked = resize_from_scale(400.0, 200.0, 50.0).unwrap(); + assert_eq!((linked.width, linked.height, linked.scale), (200.0, 100.0, 50.0)); + let from_width = resize_from_side(400.0, 200.0, 100.0, 200.0, true, true).unwrap(); + assert_eq!((from_width.width, from_width.height), (100.0, 50.0)); + let from_height = resize_from_side(400.0, 200.0, 100.0, 40.0, true, false).unwrap(); + assert_eq!((from_height.width, from_height.height), (80.0, 40.0)); + let free = resize_from_side(400.0, 200.0, 100.0, 80.0, false, true).unwrap(); + assert_eq!((free.width, free.height), (100.0, 80.0)); + assert!(resize_from_scale(400.0, 200.0, 0.0).is_err()); + assert!(resize_from_side(400.0, 200.0, 0.0, 10.0, false, true).is_err()); + assert!(resize_from_side(10.0, 10.0, 400_000.0, 10.0, false, true).is_err()); + + let doc = Document::with_background( + "x", + photocraft_doc::Size::new(400, 200), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let before = doc.clone(); + let mut f = Map::new(); + f.insert("format".into(), json!("png")); + f.insert("scale".into(), json!(100)); + f.insert("sizing".into(), json!("pixels")); + f.insert("width".into(), json!(120)); + f.insert("height".into(), json!(40)); + let out = export_document(&doc, &f, None).unwrap(); + assert_eq!((out.size.width, out.size.height), (120, 40), "explicit pixels win over the percent scale"); + let proxy = export_document(&doc, &f, Some(20)).unwrap(); + assert_eq!((proxy.size.width, proxy.size.height), (20, 7)); + assert_eq!(doc, before, "export leaves the source document unchanged"); + } + + #[test] + fn profile_and_metadata_follow_the_existing_exporter() { + use photocraft_cms::Builtin; + + let mut f = Map::new(); + f.insert("format".into(), json!("jpg")); + assert!(settings(&f).embed_metadata, "a missing metadata choice keeps EXIF and resolution"); + assert!(settings(&f).embed_icc); + f.insert("metadata".into(), json!("none")); + f.insert("embedProfile".into(), json!(false)); + let none = settings(&f); + assert!(!none.xmp_all && !none.embed_metadata && !none.embed_icc); + f.insert("metadata".into(), json!("exif")); + let exif = settings(&f); + assert!(!exif.xmp_all && exif.embed_metadata); + f.insert("metadata".into(), json!("all")); + f.insert("embedProfile".into(), json!(true)); + let all = settings(&f); + assert!(all.xmp_all && all.embed_metadata && all.embed_icc); + + let mut doc = Document::with_background( + "x", + photocraft_doc::Size::new(8, 8), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + doc.icc_profile = Some(Builtin::DisplayP3.profile().to_bytes()); + doc.metadata.xmp = Some("secret".into()); + let before = doc.clone(); + f.insert("profile".into(), json!("srgb")); + f.insert("width".into(), json!(8)); + f.insert("height".into(), json!(8)); + f.insert("metadata".into(), json!("none")); + f.insert("embedProfile".into(), json!(false)); + let converted = export_document(&doc, &f, None).unwrap(); + let parsed = photocraft_cms::Profile::parse(converted.icc_profile.as_deref().unwrap()).unwrap(); + assert!(parsed.same_colors(Builtin::Srgb.profile()), "Convert to sRGB replaces the document profile"); + let stripped = photocraft_io::export(&converted, "out.jpg", &settings(&f).io_options()).unwrap().bytes; + assert!(!stripped.windows(b"ICC_PROFILE".len()).any(|w| w == b"ICC_PROFILE")); + assert!(!stripped.windows(b"secret".len()).any(|w| w == b"secret")); + f.insert("metadata".into(), json!("all")); + f.insert("embedProfile".into(), json!(true)); + let kept = photocraft_io::export(&converted, "out.jpg", &settings(&f).io_options()).unwrap().bytes; + assert!(kept.windows(b"ICC_PROFILE".len()).any(|w| w == b"ICC_PROFILE")); + assert!(kept.windows(b"secret".len()).any(|w| w == b"secret")); + assert_eq!(doc, before); + } + + #[test] + fn applying_a_preset_restores_its_size_mode() { + use photocraft_engine::export_preset_cmds::ExportPresetSettings; + + let pixels = ExportPresetSettings { + format: "png".into(), + quality: 80, + lossless: false, + transparency: true, + scale: 100.0, + width: 64, + height: 32, + lock_aspect: false, + sizing: "pixels".into(), + metadata: "exif".into(), + profile: "document".into(), + embed_profile: false, + tga_bits: "auto".into(), + }; + let mut f = Map::new(); + apply_preset(&mut f, &pixels, 400.0, 200.0); + assert_eq!(f.get("width"), Some(&json!(64))); + assert_eq!(f.get("height"), Some(&json!(32))); + assert_eq!(f.get("sizing"), Some(&json!("pixels"))); + assert_eq!(f.get("embedProfile"), Some(&json!(false))); + let scale = ExportPresetSettings { sizing: "scale".into(), scale: 25.0, ..pixels }; + apply_preset(&mut f, &scale, 400.0, 200.0); + assert_eq!(f.get("width"), Some(&json!(100.0))); + assert_eq!(f.get("height"), Some(&json!(50.0))); + assert_eq!(f.get("sizing"), Some(&json!("scale"))); + } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 5f7dfc5c98..dc21c670a6 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -161,6 +161,116 @@ fn answer(open: &Open, answer: Option) { std::thread::spawn(move || reply.send(answer)).join().unwrap(); } +#[test] +fn menu_place_starts_transform_for_each_chosen_image() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let ctx = egui::Context::default(); + let initial_layers = app.session.active().unwrap().doc.layer_count(); + let initial_history = app.session.active().unwrap().history.past_len(); + let linked_dir = (command == "file.placeLinked").then(|| { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("photocraft-menu-place-{}-{unique}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + }); + for index in 0..2 { + menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + let picked = if let Some(dir) = &linked_dir { + let path = dir.join(format!("drawing-{index}.png")); + std::fs::write(&path, &bytes).unwrap(); + FileDialogAnswer::Paths(vec![path.to_string_lossy().into_owned()]) + } else { + FileDialogAnswer::Contents(format!("drawing-{index}.png"), bytes.clone()) + }; + answer(&open, Some(picked)); + app.poll_file_dialog(&ctx, None); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_count(), initial_layers + index + 1); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, format!("drawing-{index}")); + let transform = app.ui.transform.as_ref().expect("File > Place must offer transform handles after the picker completes"); + assert_eq!(transform.made, Some(crate::state::MadeLayer::Place)); + if index == 0 { + let layer = st.active_layer.unwrap(); + let photocraft_doc::LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("placed Smart Object") }; + let original_scale = smart.transform.m[0]; + let transform = app.ui.transform.as_mut().unwrap(); + let left = transform.quad[0][0]; + for corner in &mut transform.quad { + corner[0] = left + (corner[0] - left) * 2.0; + } + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none(), "resizing the placed image must commit"); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("placed Smart Object") }; + assert!((smart.transform.m[0] - original_scale * 2.0).abs() < 1e-8, "placement width must double"); + assert_eq!(app.session.active().unwrap().history.past_len(), initial_history + 1); + } else { + crate::transform_tool::cancel(&mut app); + assert_eq!(app.session.active().unwrap().doc.layer_count(), initial_layers + 1); + assert_eq!(app.session.active().unwrap().history.past_len(), initial_history + 1); + } + } + if let Some(dir) = linked_dir { + for index in 0..2 { + std::fs::remove_file(dir.join(format!("drawing-{index}.png"))).unwrap(); + } + std::fs::remove_dir(dir).unwrap(); + } + } +} + +#[test] +fn cancelled_or_invalid_menu_place_does_not_start_transform() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + for reply in [None, Some(FileDialogAnswer::Contents("broken.png".into(), b"not an image".to_vec()))] { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, reply); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.transform.is_none()); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + } + } +} + +#[test] +fn menu_place_preserves_an_unfinished_transform() { + for start_before_picker in [true, false] { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layerFromBackground", json!({})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let ctx = egui::Context::default(); + if !start_before_picker { + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + } + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let transform = app.ui.transform.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + if start_before_picker { + assert!(menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).is_err()); + assert!(!app.file_dialog_open()); + } else { + answer(&open, Some(FileDialogAnswer::Contents("source.png".into(), bytes))); + app.poll_file_dialog(&ctx, None); + } + assert_eq!(app.ui.transform, transform); + assert_eq!(app.session.active().unwrap().doc, doc); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + } +} + #[test] fn open_starts_in_the_last_used_folder() { let (mut app, open, _) = app(); @@ -742,6 +852,14 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { // The sibling path is built with `Path::join`, so Windows turns the separator into `\`. let second = written.borrow()[1].replace('\\', "/"); assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); + let written = written.borrow(); + assert_eq!(written.len(), 2); + // First file uses the exact path from the dialog (forward slashes in test) + assert_eq!(std::path::Path::new(&written[0]).file_name().and_then(|n| n.to_str()), Some("boards.jpg")); + // Second file is generated by batch_paths which uses platform path separators + let sibling = std::path::Path::new(&written[1]); + assert_eq!(sibling.parent(), std::path::Path::new(&written[0]).parent()); + assert_eq!(sibling.file_name().and_then(|name| name.to_str()), Some("boards-2-Capsule.jpg")); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); got.sort_unstable(); assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); @@ -794,3 +912,41 @@ fn save_as_openraster_of_what_it_cannot_hold_is_a_copy() { assert!(err.contains("OpenRaster"), "{err}"); assert_eq!(written.borrow().len(), 1); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmation() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!( + "pc-render-video-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let path = dir.to_string_lossy().into_owned(); + + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + assert_eq!(result["fileDialog"], "folder"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { .. }, _)])); + assert!(!dir.exists(), "a menu click must not create an output directory"); + + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert!(!dir.exists(), "cancelling the picker must not render video"); + + menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::FolderTo(path.clone()))); + app.poll_file_dialog(&ctx, None); + let rendered: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(rendered.len(), 1, "a document without a timeline exports one frame"); + assert_eq!(rendered[0].extension().and_then(|ext| ext.to_str()), Some("png")); + std::fs::remove_dir_all(&dir).unwrap(); + + // Commands that already supply a directory must not open a file picker. + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({"dir": path})).unwrap(); + assert!(result["frames"].as_u64().is_some()); + assert!(!app.file_dialog_open()); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index f4dacbc575..3da0735204 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -436,7 +436,7 @@ pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map) { } apply_command_defaults(app, &command, fields); // The schema has no default for the Blur Gallery's pins: start again from the opening one. - if command == crate::blur_pins::COMMAND { + if crate::blur_pins::handles(&command) { crate::blur_pins::reset(fields); } } @@ -458,7 +458,7 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { } restore_remembered(app, command, spec.params, &mut fields); apply_command_defaults(app, command, &mut fields); - if command == crate::blur_pins::COMMAND { + if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { @@ -962,7 +962,13 @@ pub struct FilterPreview { pub committing: bool, } -/// Match the full request before accepting a worker result, including a reopened dialog. +/// Identity of one filter dialog's live preview. +/// +/// `PartialEq` is the whole request: it says a worker result is exactly what the dialog is asking +/// for now. [`Self::same_target`] is the looser question of whether a result belongs to this +/// preview at all; two keys that agree on it name the same dialog, layer, document and preview +/// resolution, and differ only in parameters — which is what a slider drag produces, since the +/// worker computed for the value the pointer passed a moment ago. #[derive(Clone, Debug, PartialEq)] pub struct FilterPreviewKey { pub doc: photocraft_doc::DocId, @@ -975,6 +981,26 @@ pub struct FilterPreviewKey { pub k: u32, } +impl FilterPreviewKey { + /// Everything except the parameters: same dialog, document, revision, layer, channel target, + /// command and proxy factor. + /// + /// 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 + /// would mean no frame of a drag is ever shown, because every completion lands after the + /// parameters moved on. A reopened dialog has a new id, a different command or a different + /// document, and a committed document a new revision, so neither survives this test. + pub fn same_target(&self, other: &Self) -> bool { + self.doc == other.doc + && self.revision == other.revision + && self.dialog == other.dialog + && self.active == other.active + && self.target == other.target + && self.command == other.command + && self.k == other.k + } +} + #[cfg(test)] mod tests { use super::*; @@ -988,6 +1014,46 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } + fn key() -> FilterPreviewKey { + FilterPreviewKey { + doc: photocraft_doc::DocId(7), + revision: 11, + dialog: 3, + active: Some(photocraft_doc::LayerId(2)), + target: Default::default(), + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": 4.0}), + k: 1, + } + } + + #[test] + fn same_target_ignores_parameters_but_nothing_else() { + // A drag only changes the parameters, and that must not hide the finished frame. + let base = key(); + let mut moved = base.clone(); + moved.params = json!({"radius": 40.0}); + assert!(base.same_target(&moved)); + assert!(moved.same_target(&base)); + + // Anything else names a different preview and must not be shown. + for break_it in [ + |k: &mut FilterPreviewKey| k.doc = photocraft_doc::DocId(8), + |k: &mut FilterPreviewKey| k.revision = 12, + |k: &mut FilterPreviewKey| k.dialog = 4, + |k: &mut FilterPreviewKey| k.active = Some(photocraft_doc::LayerId(5)), + |k: &mut FilterPreviewKey| k.active = None, + |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, + ] { + let mut other = base.clone(); + break_it(&mut other); + assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); + assert!(!base.same_target(&other)); + } + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. @@ -1367,6 +1433,12 @@ mod tests { assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}"); } + #[test] + fn spanish_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap()); + assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] @@ -1824,6 +1896,38 @@ mod tests { assert_eq!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&full).px); } + /// #5766: Curves on a smart object adds a smart filter, which re-renders the source through + /// the object's placement. On a reduced proxy that placement stayed in full-size document + /// coordinates, so the preview showed the object `k` times too large until OK. + #[test] + fn smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let st = s.active().unwrap(); + let (doc, layer) = ((*st.doc).clone(), st.active_layer.unwrap()); + let alpha = |d: &Document, x: i32, y: i32| match &d.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.cache.as_ref().unwrap().pixel(x, y).last().copied().unwrap(), + _ => panic!("a smart object"), + }; + let k = 4; + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; + assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); + assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); + // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). + for (x, y) in [(4, 4), (5, 6), (7, 7)] { + assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); + } + for (x, y) in [(2, 2), (9, 9), (12, 12), (15, 15)] { + assert!(alpha(&shown, x, y) < 0.01, "({x}, {y}) is outside the reduced square"); + } + } + /// #2063: a pixel-sized parameter divided by the proxy factor was clamped to the command's /// minimum (Pointillize's 5 px cells became 3 px proxy cells = 12 px), so the preview factor /// drops until every pixel-sized value fits its range. diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 48f29400fb..3006323c20 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -160,7 +160,7 @@ mod tests { let (old, result) = finish(&mut worker); assert!(result.is_ok()); let latest = key(1, 0, 256); - assert_ne!(old, latest); // The canvas must discard this result before uploading. + assert_ne!(old, latest); // The canvas paints this as the drag's frame; the newest one is computed next. assert!(worker.start(latest.clone(), ctx, |_| Computed { result: None, ms: 1.0 }).unwrap()); assert_eq!(finish(&mut worker).0, latest); } diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 74c334d682..678da4191c 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -244,14 +244,20 @@ mod tests { fn a_system_arabic_face_renders_both_samples() { let mut engine = photocraft_text::TextEngine::new(); engine.fonts.load_system_fonts(); - let both = engine.fonts.families().into_iter().find(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()); - let Some(family) = both else { - eprintln!("skipped: no installed family covers both Latin and Arabic"); + let candidates: Vec = engine.fonts.families().into_iter().filter(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()).collect(); + // A face can map the Arabic characters to blank glyphs (#5259): `render` then rightly gives + // no sample, so every candidate is rendered and the first one with an Arabic sample is checked. + let mut rendered = None; + for family in &candidates { + if let Some(arabic) = images(&mut engine, family, 1, MEDIUM).and_then(|i| i.arabic) { + rendered.get_or_insert((family.clone(), arabic)); + } + } + let Some((family, arabic)) = rendered else { + eprintln!("skipped: no installed family renders both Latin and Arabic ({} claim both)", candidates.len()); return; }; - let images = images(&mut engine, &family, 1, MEDIUM).expect(&family); - let arabic = images.arabic.expect("Arabic sample"); - assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize]); + assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize], "{family}"); assert!(arabic.pixels.iter().any(|p| p.a() > 0), "{family}"); } } diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 28eb556282..4a1210b874 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -1,6 +1,6 @@ //! Frame tool (K): drag on the canvas to draw a rectangular or elliptical frame (the options bar //! picks the shape). ⇧ makes it a square or circle, ⌥ draws it from the centre. Drawn over the -//! selected pixel layer's content, the frame takes that layer in, as in Photoshop; else it is an +//! selected layer's content, the frame takes that layer in, as in Photoshop; else it is an //! empty placeholder. Each frame is one engine command (`layer.new.frame`), so one undo step; this //! module only turns the drag into it. @@ -48,12 +48,13 @@ fn drawn(d: &FrameDrag) -> [i64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) } -/// The selected pixel layer the frame `r` takes in: one whose content it overlaps and that can -/// move (not the Background or a position-locked layer). +/// The selected pixel, type, shape or smart object layer the frame `r` takes in: one whose content +/// it overlaps and that can move (not the Background or a position-locked layer). fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { let st = app.session.active()?; let l = st.doc.layer(st.active_layer?)?; - if !matches!(l.content, LayerContent::Raster(_)) || l.locks.position || l.locks.all { + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || l.locks.position || l.locks.all { return None; } let b = l.surface()?.content_bounds(); diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index 83928e29b4..89eac953e9 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -92,6 +92,8 @@ pub struct GalleryDialog { thumb_src: Surface, thumbs: Vec>, pub preview_ms: f64, + /// Editing smart filter `index` of the layer: OK updates it instead of adding a filter. + pub smart_filter: Option, history: EditHistory>, } @@ -104,6 +106,7 @@ impl GalleryDialog { "zoom": if self.zoom > 0.0 { json!(self.zoom) } else { json!("fit") }, "previewMs": self.preview_ms, "rendering": self.rendering().is_some(), + "smartFilter": self.smart_filter, }) } @@ -269,18 +272,12 @@ fn image(surf: &Surface, w: usize, h: usize) -> egui::ColorImage { egui::ColorImage::new([w, h], px.iter().map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], if alpha { p[3] } else { 255 })).collect()) } -/// Opens the dialog on the active layer, starting from the last gallery stack used. -pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { - let (layer, src, _) = crate::distort_ui::active_pixels(app)?; - let st = app.session.active().ok_or("no document")?; - let canvas = st.doc.bounds(); - let layer_name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let last = app.session.journal.iter().rev().find(|(id, _)| id == "filter.filterGallery").map(|(_, p)| p.clone()); - let mut effects: Vec = last - .as_ref() - .and_then(|p| p.get("effects").and_then(Value::as_array).cloned()) - .unwrap_or_default() - .iter() +/// The effect layers of a `filter.filterGallery` params object (unknown filters are skipped). +fn effects_from(p: &Value) -> Vec { + p.get("effects") + .and_then(Value::as_array) + .into_iter() + .flatten() .filter_map(|e| { let f = GalleryFilter::from_key(e.get("filter")?.as_str()?)?; let mut l = EffectLayer::new(f); @@ -290,7 +287,56 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { l.visible = e.get("visible").and_then(Value::as_bool).unwrap_or(true); Some(l) }) - .collect(); + .collect() +} + +/// A recorded `[r, g, b, a]` colour param. +fn colour_param(p: &Value, key: &str) -> Option<[f32; 4]> { + let a = p.get(key)?.as_array()?; + let c = |j: usize, d: f32| a.get(j).and_then(Value::as_f64).map_or(Some(d), |v| Some(v as f32).filter(|v| v.is_finite())); + Some([c(0, 0.0)?, c(1, 0.0)?, c(2, 0.0)?, c(3, 1.0)?]) +} + +/// Opens the dialog on the active layer, starting from the last gallery stack used. +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + let (layer, src, _) = crate::distort_ui::active_pixels(app)?; + let last = app.session.journal.iter().rev().find(|(id, _)| id == "filter.filterGallery").map(|(_, p)| p.clone()); + let effects = last.as_ref().map(effects_from).unwrap_or_default(); + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + open_on(app, layer, src, effects, [fg, bg], None) +} + +/// Re-opens Filter Gallery smart filter `index` of smart object `layer` with its recorded stack, +/// previewing on the pixels below it; OK updates that filter (`layer.smartFilter.setParams`). +pub fn open_smart_filter(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { + return Err("the layer is not a smart object".into()); + }; + let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?; + if filter.command != "filter.filterGallery" { + return Err("the smart filter is not a Filter Gallery filter".into()); + } + let p = filter.params.clone(); + let src = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) + .map_err(|e| e.to_string())? + .ok_or("the smart object's contents are unavailable (missing linked file?)")?; + let fg = colour_param(&p, "foreground").unwrap_or(app.session.tools.foreground); + let bg = colour_param(&p, "background").unwrap_or(app.session.tools.background); + open_on(app, layer, src, effects_from(&p), [fg, bg], Some(index)) +} + +fn open_on( + app: &mut PhotocraftApp, + layer: LayerId, + src: Surface, + mut effects: Vec, + colours: [[f32; 4]; 2], + smart_filter: Option, +) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let canvas = st.doc.bounds(); + let layer_name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); if effects.is_empty() { effects.push(EffectLayer::new(GalleryFilter::ColoredPencil)); } @@ -312,8 +358,8 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { effects, selected: sel, open: open_cats, - foreground: app.session.tools.foreground, - background: app.session.tools.background, + foreground: colours[0], + background: colours[1], zoom: 0.0, center: [(canvas.x0 + canvas.x1) as f32 / 2.0, (canvas.y0 + canvas.y1) as f32 / 2.0], tex: None, @@ -325,6 +371,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { thumb_src, thumbs: vec![None; GalleryFilter::ALL.len()], preview_ms: 0.0, + smart_filter, history, }); Ok(()) @@ -336,6 +383,9 @@ pub fn commit(app: &mut PhotocraftApp) -> Result { if !d.effects.iter().any(|e| e.visible) { return Ok(json!({"committed": false})); } + if let Some(index) = d.smart_filter { + return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": d.params()})); + } let mut p = d.params(); p["layer"] = json!(d.layer.0); app.run("filter.filterGallery", p) @@ -859,6 +909,51 @@ mod tests { assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "cancel records nothing"); } + #[test] + fn smart_filter_double_click_edits_the_stored_gallery_stack() { + let ctx = egui::Context::default(); + let mut app = app(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let stack = json!({"effects": [{"filter": "chrome", "params": {"detail": 2, "smoothness": 9}, "visible": true}], "foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]}); + app.run("filter.filterGallery", stack).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2.0})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let smart = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.clone(), + _ => panic!("not a smart object"), + }; + let steps = app.session.active().unwrap().history.past_len(); + + let sm = smart(&app); + crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); + assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); + let d = app.distort.gallery.as_ref().expect("the gallery dialog opens"); + assert_eq!((d.smart_filter, d.foreground, d.background), (Some(0), [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0])); + assert_eq!(d.effects.len(), 1); + assert_eq!((d.effects[0].filter, &d.effects[0].params["detail"]), (GalleryFilter::Chrome, &json!(2))); + // The preview starts from the pixels below the filter (no blur, no chrome). + let below = photocraft_engine::smart_cmds::render_below_filter(&app.session.active().unwrap().doc, &sm, 0).unwrap().unwrap(); + let d = app.distort.gallery.as_ref().unwrap(); + assert_eq!(d.src.read_region(d.canvas), below.read_region(d.canvas)); + + control(&mut app, &json!({"params": {"detail": 7}})).unwrap(); + control(&mut app, &json!({"commit": true})).unwrap(); + assert!(app.distort.gallery.is_none()); + let sm = smart(&app); + assert_eq!(sm.smart_filters.len(), 2, "edited in place, not added"); + assert_eq!(sm.smart_filters[0].command, "filter.filterGallery"); + assert_eq!(sm.smart_filters[0].params["effects"][0]["params"]["detail"], json!(7)); + assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app).smart_filters[0].params["effects"][0]["params"]["detail"], json!(2)); + + // Not a gallery filter / no such filter: an error, no dialog. + assert!(open_smart_filter(&mut app, layer, 1).is_err()); + assert!(open_smart_filter(&mut app, layer, 9).is_err()); + assert!(app.distort.gallery.is_none()); + } + #[test] fn preview_and_thumbnails_render() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 3862aec534..2e583e8e5b 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -112,6 +112,21 @@ pub struct GpuCanvas { health: photocraft_gpu::DeviceHealth, } +/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on +/// Intel adapters under DX12 only, where some drivers corrupt reduced previews when a pass +/// samples one mip level and renders into another of the same texture (#892, Intel UHD 620). +/// Everywhere else it renders straight into the level: on an NVIDIA DX12 adapter the scratch +/// textures, created per level on every refresh, cost about 1 ms of CPU time per brush dab +/// (#4515). `PHOTOCRAFT_SEPARATE_MIPS` (`setting`) forces it on (`1`) or off (`0`). +pub fn separate_mip_targets(backend: wgpu::Backend, vendor: u32, setting: Option<&str>) -> bool { + const INTEL: u32 = 0x8086; + match setting.map(str::trim) { + Some("1") => true, + Some("0") => false, + _ => backend == wgpu::Backend::Dx12 && vendor == INTEL, + } +} + /// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture? /// (Core WebGPU guarantees it; WebGL2 needs `EXT_color_buffer_float`, and some drivers lack it.) pub fn supports_f16_canvas(adapter: &wgpu::Adapter) -> bool { @@ -141,7 +156,8 @@ impl GpuCanvas { let health = photocraft_gpu::DeviceHealth::watch(&rs.device); let mut res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off); res.health = health.clone(); - res.separate_mip_targets = rs.adapter.get_info().backend == wgpu::Backend::Dx12; + let info = rs.adapter.get_info(); + res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref()); rs.renderer.write().callback_resources.insert(res); log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); Self { rs: rs.clone(), tile, high, health } @@ -432,8 +448,10 @@ impl GpuCanvas { } // A full refresh whose layer pages don't fit the memory budget would re-upload the // evicted ones every time (no faster than the CPU, and more memory): the banded CPU - // compositor does it, and edits in the view (damage rects) stay on the GPU. - if region == doc.bounds() && !comp.fits_budget(doc, region) { + // compositor does it (in the background on desktop). So does a large damage rect, such + // as hiding a big layer (#4024); small edits in the view stay on the GPU. + let large = region.width() as u64 * region.height() as u64 >= DEFER_PX; + if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) { res.compositor = Some(comp); return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); } @@ -1283,7 +1301,7 @@ struct Resources { views: HashMap, out_linear: bool, /// Build each mip level in a scratch texture and copy it back instead of rendering into - /// the level directly (DX12 only: works around reduced-preview corruption on Intel drivers). + /// the level directly (see [`separate_mip_targets`]). separate_mip_targets: bool, /// The wgpu layer compositor (created on first use). compositor: Option, @@ -1692,8 +1710,8 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }, ], }); - // Keep the sampled source and render attachment in separate resources on DX12 - // (`Resources::separate_mip_targets`): some Intel DX12 drivers corrupt reduced + // Keep the sampled source and render attachment in separate resources on Intel + // DX12 adapters ([`separate_mip_targets`]): some of their drivers corrupt reduced // previews when both are mip levels of the same texture, even though the // subresources do not overlap. Elsewhere render straight into the level, which // spares a scratch texture and a copy per level on every edit. @@ -2199,6 +2217,23 @@ mod mip_tests; mod tests { use super::*; + /// The scratch-texture workaround is for Intel's DX12 drivers (#892); other DX12 adapters + /// render straight into the level like every other backend (#4515). + #[test] + fn separate_mip_targets_only_for_intel_on_dx12() { + use wgpu::Backend::{Dx12, Metal, Vulkan}; + let (intel, nvidia, amd) = (0x8086, 0x10de, 0x1002); + assert!(separate_mip_targets(Dx12, intel, None)); + assert!(!separate_mip_targets(Dx12, nvidia, None)); + assert!(!separate_mip_targets(Dx12, amd, None)); + assert!(!separate_mip_targets(Vulkan, intel, None)); + assert!(!separate_mip_targets(Metal, 0, None)); + // PHOTOCRAFT_SEPARATE_MIPS forces either way; anything else leaves the default. + assert!(separate_mip_targets(Dx12, nvidia, Some("1"))); + assert!(!separate_mip_targets(Dx12, intel, Some(" 0 "))); + assert!(separate_mip_targets(Dx12, intel, Some("yes"))); + } + #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index 8d47e628c8..894535e687 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -194,6 +194,9 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) let mut value = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; if widgets::value_field(ui, &mut value, (if count { 1.0 } else { 0.0 })..=if count { 1000.0 } else { 300_000.0 }, if count { "" } else { "px" }, 68.0) .changed() + // A disabled field still clamps its value into range (a zero count to 1); writing that + // back would switch the axis on through its `positive` fallback (#5625). + && ui.is_enabled() { f.insert(key.into(), if count { json!(value.round() as u64) } else { json!(value) }); } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 299b3fb83e..673f1663bf 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -104,6 +104,32 @@ fn tab_skips_disabled_rows_and_margins() { assert_eq!(f["gutter"], 4.0); } +/// The disabled Number field must not clamp a zero count to 1 and so switch its axis on (#5625). +#[test] +fn disabled_number_fields_do_not_turn_their_axis_on() { + let mut app = app(); + let id = open(&mut app); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["rows"], 0, "the default layout has no rows"); + assert!(!on(f, "__rowsEnabled", positive(f, "rows")), "Rows stays off"); + let mut app = h.into_state(); + crate::dialogs::confirm(&mut app, id).unwrap(); + let guides = &app.session.active().unwrap().doc.guides; + assert!(!guides.vertical.is_empty(), "the columns were created"); + assert!(guides.horizontal.is_empty(), "no row guides at the top and bottom"); + + let mut app = self::app(); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(0)); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["columns"], 0); + assert!(!on(f, "__columnsEnabled", positive(f, "columns")), "Columns stays off"); +} + /// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom. #[test] fn color_list_is_new_guides() { diff --git a/crates/ui-egui/src/guide_select.rs b/crates/ui-egui/src/guide_select.rs new file mode 100644 index 0000000000..815d02862f --- /dev/null +++ b/crates/ui-egui/src/guide_select.rs @@ -0,0 +1,110 @@ +//! Selecting a guide on the canvas, as in Photoshop: a click on a guide with the Move tool selects +//! it (drawn in Photoshop's selected-guide blue), a press anywhere else deselects it, Delete +//! removes it, and right-click opens the guide menu (`canvas_tool_menu`), whose Edit Selected +//! Guides reopens New Guide on it. +//! +//! One guide at a time. The selection is view state, not a history step: it names the guide by +//! document, orientation and position, as guide colours do, so it lapses by itself when the guide +//! moves away, is deleted or undone, and the index is looked up when it is used. + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Photoshop draws a selected guide in this blue, whatever the guide's own colour. +pub const SELECTED_COLOR: egui::Color32 = egui::Color32::from_rgb(0x00, 0x00, 0xff); + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SelectedGuide { + /// The document's index among the open documents. + pub doc: usize, + pub vertical: bool, + pub position: f64, +} + +/// The selected guide's orientation and index in the active document, while guides can be +/// interacted with (shown, unlocked). +pub fn selected(app: &PhotocraftApp) -> Option<(bool, usize)> { + let sel = app.ui.selected_guide?; + if !interactive(app) || app.session.active_index() != Some(sel.doc) { + return None; + } + let guides = &app.session.active()?.doc.guides; + let list = if sel.vertical { &guides.vertical } else { &guides.horizontal }; + list.iter().position(|p| *p == sel.position).map(|i| (sel.vertical, i)) +} + +/// Is the guide at `position` the selected one (drawn blue)? +pub fn is_selected(app: &PhotocraftApp, vertical: bool, position: f64) -> bool { + selected(app).is_some() && app.ui.selected_guide.is_some_and(|s| s.vertical == vertical && s.position == position) +} + +/// Guides take clicks only while they're shown and unlocked (as `rulers::guide_at` requires). +fn interactive(app: &PhotocraftApp) -> bool { + app.ui.extras.guides && app.ui.view.shows(app.ui.view.show.canvas_guides) && !app.ui.extras.lock_guides +} + +/// Select guide `index` of the active document. +pub fn select(app: &mut PhotocraftApp, vertical: bool, index: usize) { + let Some(doc) = app.session.active_index() else { return }; + let Some(guides) = app.session.active().map(|st| &st.doc.guides) else { return }; + let list = if vertical { &guides.vertical } else { &guides.horizontal }; + app.ui.selected_guide = list.get(index).map(|position| SelectedGuide { doc, vertical, position: *position }); +} + +pub fn clear(app: &mut PhotocraftApp) { + app.ui.selected_guide = None; +} + +/// Delete the selected guide (Delete, Clear Selected Guides). False when no guide is selected. +pub fn delete_selected(app: &mut PhotocraftApp) -> Result { + let Some((vertical, index)) = selected(app) else { return Ok(false) }; + app.run("view.deleteGuide", delete_params(vertical, index))?; + clear(app); + Ok(true) +} + +pub fn delete_params(vertical: bool, index: usize) -> serde_json::Value { + json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "index": index}) +} + +/// ⌥-click on guide `index`: turn it, so a vertical guide becomes horizontal through the point +/// clicked (`at`, document pixels) and the other way round, as one undoable step (`view.editGuide`); +/// it stays selected. +pub fn flip(app: &mut PhotocraftApp, vertical: bool, index: usize, at: [f64; 2]) { + let (from, to) = if vertical { ("vertical", "horizontal") } else { ("horizontal", "vertical") }; + let position = if vertical { at[1] } else { at[0] }; + let params = json!({"orientation": from, "index": index, "newOrientation": to, "position": (position * 1000.0).round() / 1000.0}); + match app.run("view.editGuide", params) { + Ok(_) => { + let last = app.session.active().map(|st| if vertical { st.doc.guides.horizontal.len() } else { st.doc.guides.vertical.len() }); + if let Some(n) = last.and_then(|n| n.checked_sub(1)) { + select(app, !vertical, n); + } + } + Err(e) => app.ui.status = e, + } +} + +/// Delete or Backspace with no modifiers while a guide is selected deletes the guide, before Edit › +/// Clear can reach the pixels, as in Photoshop. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them (#1534). + if selected(app).is_none() || app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + if let Err(e) = delete_selected(app) { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/guide_select/tests.rs b/crates/ui-egui/src/guide_select/tests.rs new file mode 100644 index 0000000000..a591f47547 --- /dev/null +++ b/crates/ui-egui/src/guide_select/tests.rs @@ -0,0 +1,308 @@ +use egui::{Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; +use crate::canvas_tool_menu::{GUIDE_CLEAR, GUIDE_EDIT}; +use crate::state::Tool; + +/// A 200 × 100 document with the Move tool, a vertical guide at 50 and a magenta horizontal one +/// at 30. +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.sync_views(); + app.ui.tool = Tool::Move; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30, "color": "magenta"})).unwrap(); + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE); +} + +fn guides(app: &PhotocraftApp) -> (Vec, Vec) { + let g = &app.session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn a_click_selects_a_guide_without_moving_it_and_a_press_elsewhere_deselects() { + let mut app = app(); + let steps = past(&app); + click(&mut app, 50.0, 70.0); + assert_eq!(selected(&app), Some((true, 0))); + assert!(is_selected(&app, true, 50.0) && !is_selected(&app, false, 30.0)); + assert_eq!((guides(&app), past(&app)), ((vec![50.0], vec![30.0]), steps), "a click is not a move"); + // One guide at a time: clicking another selects it instead. + click(&mut app, 120.0, 30.0); + assert_eq!(selected(&app), Some((false, 0))); + click(&mut app, 120.0, 70.0); + assert_eq!(selected(&app), None); + // A press with another tool deselects too. + click(&mut app, 50.0, 70.0); + app.ui.tool = Tool::RectMarquee; + tool_event(&mut app, ToolEvent::Down { x: 150.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(selected(&app), None); +} + +#[test] +fn a_moved_guide_stays_selected_and_dragging_one_off_the_canvas_deletes_it() { + let mut app = app(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 80.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 80.0, y: 70.0 }, Modifiers::NONE); + assert_eq!(guides(&app).0, vec![80.0]); + assert_eq!(selected(&app), Some((true, 0))); + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: -20.0, y: 70.0 }, Modifiers::NONE); + assert!(guides(&app).0.is_empty()); + assert_eq!(selected(&app), None); +} + +#[test] +fn the_selection_lapses_when_its_guide_goes_or_guides_are_hidden_or_locked() { + let mut app = app(); + click(&mut app, 50.0, 70.0); + app.ui.extras.lock_guides = true; + assert_eq!(selected(&app), None, "locked guides can't be selected"); + app.ui.extras.lock_guides = false; + app.ui.extras.guides = false; + assert_eq!(selected(&app), None, "nor hidden ones"); + app.ui.extras.guides = true; + assert_eq!(selected(&app), Some((true, 0))); + app.run("view.clearGuides", json!({})).unwrap(); + assert_eq!(selected(&app), None); + app.session.undo(); + assert_eq!(selected(&app), Some((true, 0)), "undo brings the guide back, still selected"); + // Another document's guide at the same place isn't the selected one. + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + assert_eq!(selected(&app), None); +} + +#[test] +fn delete_removes_the_selected_guide_and_leaves_the_pixels() { + let mut app = app(); + app.run("select.all", json!({})).unwrap(); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key| { + let modifiers = Modifiers::NONE; + 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(); + }; + click(&mut app, 120.0, 30.0); + press(&mut app, Key::Delete); + assert_eq!(guides(&app), (vec![50.0], vec![])); + assert_eq!(selected(&app), None); + click(&mut app, 50.0, 70.0); + press(&mut app, Key::Backspace); + assert_eq!(guides(&app), (vec![], vec![])); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo_label(), Some("Delete Guide"), "Edit › Clear didn't run"); + assert!(st.doc.selection.is_some()); + // One undoable step per guide. + app.session.undo(); + assert_eq!(guides(&app), (vec![50.0], vec![])); +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 700.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)); + crate::canvas_tool_menu::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + h +} + +fn right_click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// The vertical guide through the middle of the document, on screen: the view centres the image. +fn guide_on_screen(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + h.state().last_canvas_rect.center() + vec2(0.0, 20.0) +} + +fn centred_guide_app() -> PhotocraftApp { + let mut app = app(); + app.run("view.clearGuides", json!({})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 100})).unwrap(); + app +} + +#[test] +fn right_click_on_a_guide_selects_it_and_opens_photoshops_guide_menu() { + let mut h = harness(centred_guide_app()); + let at = guide_on_screen(&h); + right_click(&mut h, at); + let menu = h.state().ui.canvas_tool_menu.clone().expect("the guide menu"); + assert!(menu.guide); + assert_eq!(selected(h.state()), Some((true, 0))); + let labels: Vec> = crate::canvas_tool_menu::rows(&menu).iter().map(|r| r.map(|(label, _)| label)).collect(); + assert_eq!( + labels, + [ + Some("Edit Selected Guides"), + None, + Some("Lock Guides"), + Some("Clear Guides"), + Some("Clear Canvas Guides"), + Some("Clear Selected Guides"), + Some("Clear Selected Artboard Guides"), + ] + ); + let app = h.state(); + for id in [GUIDE_EDIT, GUIDE_CLEAR, "view.lockGuides", "view.clearGuides", "view.clearCanvasGuides"] { + assert!(crate::canvas_tool_menu::available(app, &menu, id), "{id}"); + } + assert!(!crate::canvas_tool_menu::available(app, &menu, "view.clearSelectedArtboardGuides"), "no artboard"); + assert!(!crate::canvas_tool_menu::available(app, &menu, "edit.freeTransform"), "not a guide row"); + // Off a guide, the Move tool's right-click is still the layer picker's. + h.key_press(Key::Escape); + h.run_steps(2); + let off = h.state().last_canvas_rect.center() + vec2(-60.0, 20.0); + right_click(&mut h, off); + assert!(h.state().ui.canvas_tool_menu.is_none()); +} + +#[test] +fn guide_menu_rows_clear_the_selected_guide_and_lock_guides() { + let mut h = harness(centred_guide_app()); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + let at = guide_on_screen(&h); + right_click(&mut h, at); + let ctx = h.ctx.clone(); + crate::canvas_tool_menu::choose(h.state_mut(), &ctx, GUIDE_CLEAR); + assert_eq!(guides(h.state()).0, vec![20.0], "only the right-clicked guide goes"); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 100})).unwrap(); + h.run_steps(2); + let at = guide_on_screen(&h); + right_click(&mut h, at); + crate::canvas_tool_menu::choose(h.state_mut(), &ctx, "view.lockGuides"); + assert!(h.state().ui.extras.lock_guides); + assert_eq!(selected(h.state()), None, "locked guides aren't selected"); +} + +#[test] +fn edit_selected_guides_opens_new_guide_on_the_guide_and_applies_the_edit() { + let mut app = app(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + click(&mut app, 120.0, 30.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + assert_eq!((f["orientation"].as_str(), f["position"].as_str(), f["color"].as_str()), (Some("horizontal"), Some("30 %"), Some("magenta"))); + assert_eq!(f["__label"], "Edit Selected Guides"); + // Turn it vertical at 30 % of the width; the colour isn't touched, so it keeps its own. + let mut edited = f.clone(); + edited.insert("orientation".into(), json!("vertical")); + edited.insert("position".into(), json!("30")); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![50.0, 60.0], vec![])); + let g = &app.session.active().unwrap().doc.guides; + assert_eq!((g.color(true, 60.0), g.color(false, 30.0)), (Some([0xff, 0x00, 0xff]), None)); + assert_eq!(selected(&app), Some((true, 1)), "the edited guide stays selected"); +} + +#[test] +fn editing_an_uncoloured_guide_keeps_the_preference_colour_unless_one_is_chosen() { + let mut app = app(); + click(&mut app, 50.0, 70.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + // The default preference colour is Cyan's, so a guide dragged from a ruler shows as Cyan. + assert_eq!(f["color"].as_str(), Some("cyan")); + let params = crate::new_guide_ui::params(&app, &f); + assert!(params.get("color").is_none(), "{params}"); + let mut yellow = f.clone(); + yellow.insert("color".into(), json!("yellow")); + assert_eq!(crate::new_guide_ui::params(&app, &yellow)["color"], "yellow"); + // A refused position keeps the dialog open with the message and the guide untouched. + let mut bad = f.clone(); + bad.insert("position".into(), json!("far")); + app.ui.dialogs.retain(|d| d.id != id); + assert!(crate::new_guide_ui::confirm(&mut app, &bad).is_err()); + assert_eq!(guides(&app).0, vec![50.0]); + assert!(app.ui.dialogs.last().is_some_and(|d| d.fields.contains_key("__error"))); + // A preference colour that isn't a named one shows as Custom…. + app.ui.dialogs.clear(); + app.run("prefs.set", json!({"path": "guidesGridAndSlices.guideColor", "value": "#123456"})).unwrap(); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#123456"))); +} + +#[test] +fn edit_selected_guides_needs_a_selected_guide() { + let mut app = app(); + assert!(crate::new_guide_ui::open_edit(&mut app).is_err()); + assert!(app.ui.dialogs.is_empty()); +} + +/// ⌥-click turns a guide through the point clicked, keeping its colour, as one undoable step, and +/// leaves it selected; a locked guide can't be turned. +#[test] +fn alt_click_turns_a_guide_through_the_point_clicked() { + let mut app = app(); + let alt = Modifiers::ALT; + tool_event(&mut app, ToolEvent::Down { x: 120.0, y: 30.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 30.0 }, alt); + assert_eq!(guides(&app), (vec![50.0, 120.0], vec![]), "the magenta horizontal guide is now vertical at x = 120"); + let g = &app.session.active().unwrap().doc.guides; + assert_eq!(g.color(true, 120.0), Some([0xff, 0x00, 0xff])); + assert_eq!(selected(&app), Some((true, 1))); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Edit Guide")); + app.session.undo(); + assert_eq!(guides(&app), (vec![50.0], vec![30.0])); + app.ui.extras.lock_guides = true; + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 70.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 70.0 }, alt); + assert_eq!(guides(&app), (vec![50.0], vec![30.0]), "locked guides stay put"); +} + +/// Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop. +#[test] +fn double_click_on_a_guide_opens_the_guide_preferences() { + let mut h = harness(centred_guide_app()); + let at = guide_on_screen(&h); + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..2 { + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + let dialog = h.state().ui.dialogs.last().map(|d| serde_json::to_string(&d.fields).unwrap_or_default()).unwrap_or_default(); + assert!(dialog.contains("guidesGridAndSlices"), "{dialog}"); + assert_eq!(guides(h.state()).0, vec![100.0], "the guide didn't move"); +} diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index c33e7a21e9..93a609f946 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,5 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish, -Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. +PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, +Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, +at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -8,6 +9,11 @@ PhotoCraft's English labels; no proprietary translation resources were used. German translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. +Spanish translations: contributed by @gorevyoneticisi with OpenAI Codex as an AI contributor. +The resulting catalog is licensed under MIT OR Apache-2.0 as stated above; the translations are +original translations of PhotoCraft's English labels, and no proprietary translation resources +were used. + Brazilian Portuguese translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 01a8b9e4d9..5cfc0f8ff5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -562,6 +562,9 @@ # Field Blur pins (#3373) No pin selected Není vybrán žádný špendlík Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Tažením úchytů na obrysu ho změníte a otočíte, čtverečkem zaoblíte rohy a body uvnitř nastavíte, kde začíná rozostření. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Tažením špendlíku přesunete pás, tažením jeho prstence změníte rozostření. Plné čáry rozšiřují ostrý pás, přerušované nastavují přechod a body ho otáčejí. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Tažením úchytů na obrysu ho změníte a otočíte; prstenec nastavuje úhel rozostření. Iris Blur… Rozostření clonou… Tilt-Shift… Tilt-shift… Path Blur… Rozostření podél cesty… @@ -997,7 +1000,6 @@ Contiguous Souvislé Contrast Kontrast Control: Řízení: - Convert document's colors to the working space Převést barvy dokumentu do pracovního prostoru barev Convert to sRGB Převést do sRGB Copies Kopie Copper Měď @@ -1052,6 +1054,7 @@ Delete Group Odstranit skupinu Delete Layer Mask Odstranit masku vrstvy Delete Layers Odstranit vrstvy + Add Shortcut Přidat zkratku Delete Shortcut Odstranit zkratku Delete Timeline Odstranit časovou osu Delete Tool Preset Odstranit přednastavení nástroje @@ -1580,7 +1583,6 @@ Use Default Použít výchozí Use Legacy Použít starší Use Measurement Scale Použít měřítko měření - Use the embedded profile (instead of the working space) Použít vložený profil (místo pracovního prostoru barev) Variables bind a layer's visibility, text or pixels to a named data slot. Proměnné vážou viditelnost, text nebo pixely vrstvy na pojmenované datové pole. Vertical scale Svislé měřítko Vibrance Živost @@ -1909,6 +1911,8 @@ Camera Raw Defaults Výchozí nastavení Camera Raw Canvas border Okraj plátna Show canvas dots Zobrazit tečky plátna + Clip thumbnails to layer bounds Oříznout miniatury na hranice vrstvy + Clip Thumbnails to Layer Bounds Oříznout miniatury na hranice vrstvy Canvas color Barva plátna Canvas custom color Vlastní barva plátna Color picker Výběr barvy @@ -2099,7 +2103,8 @@ Expand group ({key}-click: all groups) Rozbalit skupinu ({key}+kliknutí: všechny skupiny) Fade steps Kroky zeslabení Hide the layer's effects ({key}-click: all layers) Skrýt efekty vrstvy ({key}+kliknutí: všechny vrstvy) - Import Brushes… (.abr) Importovat štětce… (.abr) + Import Brushes… (.abr or image) Importovat štětce… (.abr nebo obrázek) + Import Brush from Image… Importovat štětec z obrázku… Move Group Down Posunout skupinu dolů Move Group Up Posunout skupinu nahoru Reset the brush to the defaults Obnovit výchozí nastavení štětce @@ -2214,7 +2219,6 @@ Cancelled opening {name} Otevírání {name} zrušeno The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nemá vložený profil {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}. - Embedded: {embedded} Vložený: {embedded} Working: {working} Pracovní: {working} Developing {name} Vyvolávání {name} Cancelled {label} Zrušeno: {label} @@ -2504,6 +2508,8 @@ fontWeight Italic Kurzíva Color Picker (Layer Style Color) Výběr barvy (barva stylu vrstvy) Auto Hide Notifications Automaticky skrývat oznámení Notification Duration (seconds) Trvání oznámení (sekundy) + Bundle Arrow-Key Nudges Slučovat posuny šipkami + Nudge Bundle Pause (ms) Pauza slučování posunů (ms) Appearance Mode Režim vzhledu Dark Theme Tmavý motiv Light Theme Světlý motiv @@ -3482,6 +3488,7 @@ cameraRaw Vibrance Živost cameraRaw Whites Bílé layerLabel No Color Žádná barva Loading fonts… Načítání písem… + Response curve Křivka odezvy Simulate Pressure Simulovat přítlak Background Eraser Guma pozadí Healing Brush Retušovací štětec @@ -3717,6 +3724,7 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Show home recent files Zobrazit nedávné soubory na úvodní obrazovce Clear Brush Controls Obnovit ovládací prvky stopy Reset All Locked Settings Obnovit všechna zamknutá nastavení Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů @@ -3743,6 +3751,9 @@ navigator Zoom Zvětšení Action playback stopped Přehrávání akce zastaveno Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí + Background (Default) Pozadí (výchozí) +# Batch / Image Processor + Include All Subfolders Zahrnout všechny podsložky Auto Add/Delete Automaticky přidat/odstranit # Native Windows printing qualification @@ -3752,3 +3763,23 @@ navigator Zoom Zvětšení Sent to {printer} Odesláno do {printer} set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Použít vložený profil ({profile}) + Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile}) + Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev. + Button Mode Režim tlačítka + Button Mode On Režim tlačítka zapnutý + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Ztmavit rozhraní + Lighten Interface Zesvětlit rozhraní +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Tvrdé tesání + Chisel Soft Měkké tesání + Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Upravit vybraná vodítka + Clear Selected Guides Vymazat vybraná vodítka diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 489647ed21..05ac77803c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -561,6 +561,9 @@ # Field Blur pins (#3373) No pin selected Kein Pin ausgewählt Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Ziehen Sie die Griffe am Umriss, um ihn zu skalieren und zu drehen, das Quadrat, um die Ecken abzurunden, und die Punkte innen, um festzulegen, wo die Unschärfe beginnt. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Ziehen Sie den Pin, um das Band zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Ziehen Sie die durchgezogenen Linien, um das scharfe Band zu verbreitern, die gestrichelten für den Übergang und die Punkte zum Drehen. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Ziehen Sie die Griffe am Umriss, um ihn zu skalieren und zu drehen; der Ring legt den Unschärfewinkel fest. Iris Blur… Irisweichzeichner… Tilt-Shift… Tilt-Shift… Path Blur… Pfadweichzeichner… @@ -978,7 +981,6 @@ Contiguous Benachbart Contrast Kontrast Control: Steuerung: - Convert document's colors to the working space Farben des Dokuments in den Arbeitsfarbraum konvertieren Convert to sRGB In sRGB konvertieren Copies Kopien Copper Kupfer @@ -1035,6 +1037,7 @@ Delete Group Gruppe löschen Delete Layer Mask Ebenenmaske löschen Delete Layers Ebenen löschen + Add Shortcut Kurzbefehl hinzufügen Delete Shortcut Kurzbefehl löschen Delete Timeline Zeitleiste löschen Delete Tool Preset Werkzeugvorgabe löschen @@ -1562,7 +1565,6 @@ Use Default Standard verwenden Use Legacy Legacy verwenden Use Measurement Scale Messskala verwenden - Use the embedded profile (instead of the working space) Eingebettetes Profil verwenden (statt des Arbeitsfarbraums) Variables bind a layer's visibility, text or pixels to a named data slot. Variablen binden Sichtbarkeit, Text oder Pixel einer Ebene an einen benannten Datenslot. Vertical scale Vertikale Skalierung Vibrance Dynamik @@ -1883,6 +1885,8 @@ Camera Raw Defaults Camera-Raw-Standardeinstellungen Canvas border Zeichenflächenrand Show canvas dots Punkte der Zeichenfläche anzeigen + Clip thumbnails to layer bounds Miniaturbilder auf Ebenenbegrenzung beschneiden + Clip Thumbnails to Layer Bounds Miniaturbilder auf Ebenenbegrenzung beschneiden Canvas color Zeichenflächenfarbe Canvas custom color Benutzerdefinierte Zeichenflächenfarbe Color picker Farbwähler @@ -2071,7 +2075,8 @@ Expand group ({key}-click: all groups) Gruppe ausklappen ({key}-Klick: alle Gruppen) Fade steps Verblassen-Schritte Hide the layer's effects ({key}-click: all layers) Effekte der Ebene ausblenden ({key}-Klick: alle Ebenen) - Import Brushes… (.abr) Pinsel importieren… (.abr) + Import Brushes… (.abr or image) Pinsel importieren… (.abr oder Bild) + Import Brush from Image… Pinsel aus Bild importieren… Move Group Down Gruppe nach unten Move Group Up Gruppe nach oben Reset the brush to the defaults Pinsel auf Standardwerte zurücksetzen @@ -2184,7 +2189,6 @@ Cancelled opening {name} Öffnen von {name} abgebrochen The document “{name}” does not have an embedded {mode} profile. Das Dokument „{name}“ hat kein eingebettetes {mode}-Profil. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt. - Embedded: {embedded} Eingebettet: {embedded} Working: {working} Arbeitsfarbraum: {working} Developing {name} {name} wird entwickelt Cancelled {label} {label} abgebrochen @@ -2544,6 +2548,8 @@ fontWeight Italic Kursiv Color Picker (Layer Style Color) Farbwähler (Ebenenstilfarbe) Auto Hide Notifications Benachrichtigungen automatisch ausblenden Notification Duration (seconds) Dauer der Benachrichtigung (Sekunden) + Bundle Arrow-Key Nudges Pfeiltasten-Verschiebungen bündeln + Nudge Bundle Pause (ms) Pause für gebündelte Verschiebungen (ms) Appearance Mode Erscheinungsmodus Dark Theme Dunkles Design Light Theme Helles Design @@ -2671,6 +2677,7 @@ cameraData Lens Objektiv Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen Loading fonts… Schriften werden geladen… + Response curve Reaktionskurve Tool Werkzeug Simulate Pressure Druck simulieren Stroke Path… Pfad nachziehen… @@ -3107,6 +3114,7 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Show home recent files Zuletzt geöffnete Dateien auf dem Startbildschirm anzeigen Clear Brush Controls Pinselsteuerungen löschen Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren @@ -3133,6 +3141,10 @@ curves Smooth Glätten Action playback stopped Aktionswiedergabe angehalten Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog @plural {n} action|{n} actions {n} Aktion|{n} Aktionen + Background (Default) Hintergrund (Standard) + Content-Aware Fill Inhaltsbasiert füllen +# Batch / Image Processor + Include All Subfolders Alle Unterordner einschließen Auto Add/Delete Autom. hinzuf./löschen # Native Windows printing qualification @@ -3142,3 +3154,26 @@ curves Smooth Glätten Sent to {printer} An {printer} gesendet set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Eingebettetes Profil verwenden ({profile}) + Convert to the working space ({profile}) In den Arbeitsfarbraum konvertieren ({profile}) + Remember this choice for future images Diese Auswahl für künftige Bilder merken + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Die Abfrage lässt sich mit „Ask On Mismatch“ unter Bearbeiten › Farbeinstellungen wieder einschalten. + Button Mode Schaltflächenmodus + Button Mode On Schaltflächenmodus an + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Benutzeroberfläche abdunkeln + Lighten Interface Benutzeroberfläche aufhellen +# Bevel & Emboss Technique. + Technique Technik + Chisel Hard Meißeln hart + Chisel Soft Meißeln weich + Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen +# Export As metadata and profile options + Color Profile Farbprofil + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides Ausgewählte Hilfslinien bearbeiten + Clear Selected Guides Ausgewählte Hilfslinien löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 410da1a188..c6c5ff8f66 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -768,6 +768,7 @@ Default restored. Έγινε επαναφορά των προεπιλογών. Define at least one variable first. Ορίστε πρώτα τουλάχιστον μία μεταβλητή. Delete Group Διαγραφή ομάδας + Add Shortcut Προσθήκη συντόμευσης Delete Shortcut Διαγραφή συντόμευσης Delete Timeline Διαγραφή χρονογραμμής Delete count group Διαγραφή ομάδας καταμέτρησης @@ -1612,7 +1613,6 @@ Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name} The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}. - Embedded: {embedded} Ενσωματωμένο: {embedded} Working: {working} Χώρος εργασίας: {working} Cancelled {label} Ακυρώθηκε: {label} Esc to cancel Esc για ακύρωση @@ -1656,6 +1656,8 @@ Camera Raw Defaults Προεπιλογές Camera Raw Canvas border Περίγραμμα καμβά Show canvas dots Εμφάνιση κουκκίδων καμβά + Clip thumbnails to layer bounds Περικοπή μικρογραφιών στα όρια του επιπέδου + Clip Thumbnails to Layer Bounds Περικοπή μικρογραφιών στα όρια του επιπέδου Canvas color Χρώμα καμβά Canvas custom color Προσαρμοσμένο χρώμα καμβά Color picker Επιλογέας χρώματος @@ -2239,7 +2241,6 @@ Control: Χειριστήριο: Conté Crayon Μολύβι κοντέ Convert Frames > Video Layers Μετατροπή καρέ > Επίπεδα βίντεο - Convert document's colors to the working space Μετατροπή των χρωμάτων του εγγράφου στον χώρο εργασίας Convert to Linked Μετατροπή σε συνδεδεμένο Convert to Paragraph Text Μετατροπή σε κείμενο παραγράφου Convert to Point Text Μετατροπή σε κείμενο σημείου @@ -2401,6 +2402,9 @@ # Field Blur pins (#3373) No pin selected Δεν έχει επιλεγεί καρφίτσα Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Σύρετε τις λαβές στο περίγραμμα για αλλαγή μεγέθους και περιστροφή, το τετράγωνο για στρογγύλεμα των γωνιών και τις κουκκίδες μέσα για να ορίσετε πού αρχίζει η θόλωση. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Σύρετε την καρφίτσα για να μετακινήσετε τη ζώνη ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Σύρετε τις συνεχείς γραμμές για να διευρύνετε την ευκρινή ζώνη, τις διακεκομμένες για τη μετάβαση και τις κουκκίδες για περιστροφή. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Σύρετε τις λαβές στο περίγραμμα για αλλαγή μεγέθους και περιστροφή· ο δακτύλιος ορίζει τη γωνία θόλωσης. File Info Πληροφορίες αρχείου File Settings Ρυθμίσεις αρχείου Fill Path Γέμισμα διαδρομής @@ -2531,7 +2535,8 @@ Image Size: Μέγεθος εικόνας: Image interpolation Παρεμβολή εικόνας Image previews Προεπισκοπήσεις εικόνων - Import Brushes… (.abr) Εισαγωγή πινέλων… (.abr) + Import Brushes… (.abr or image) Εισαγωγή πινέλων… (.abr ή εικόνα) + Import Brush from Image… Εισαγωγή πινέλου από εικόνα… Import Presets Εισαγωγή προεπιλογών Imported Εισήχθη In Είσοδος @@ -3219,7 +3224,6 @@ Use File › Open to open an image. Χρησιμοποιήστε Αρχείο › Άνοιγμα για να ανοίξετε μια εικόνα. Use Legacy Χρήση παλαιάς λειτουργίας Use Measurement Scale Χρήση κλίμακας μέτρησης - Use the embedded profile (instead of the working space) Χρήση ενσωματωμένου προφίλ (αντί του χώρου εργασίας) Uses the foreground and background colours. Χρησιμοποιεί τα χρώματα προσκηνίου και φόντου. Vanishing Point Σημείο φυγής Vanishing Point… Σημείο φυγής… @@ -3609,6 +3613,8 @@ fontWeight Thin Πολύ λεπτά Color Picker ({name}) Επιλογέας χρώματος ({name}) Auto Hide Notifications Αυτόματη απόκρυψη ειδοποιήσεων Notification Duration (seconds) Διάρκεια ειδοποίησης (δευτερόλεπτα) + Bundle Arrow-Key Nudges Ομαδοποίηση μετατοπίσεων με βέλη + Nudge Bundle Pause (ms) Παύση ομαδοποίησης μετατοπίσεων (ms) Appearance Mode Λειτουργία εμφάνισης Pro Medium Pro Medium Studio Light Studio Light @@ -3727,6 +3733,7 @@ cameraData Lens Φακός Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις Loading fonts… Φόρτωση γραμματοσειρών… + Response curve Καμπύλη απόκρισης Simulate Pressure Προσομοίωση πίεσης Stroke Path… Περίγραμμα διαδρομής… Background Eraser Γόμα φόντου @@ -3807,6 +3814,7 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Show home recent files Εμφάνιση πρόσφατων αρχείων στην αρχική οθόνη Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία @@ -3833,6 +3841,9 @@ curves Smooth Εξομάλυνση Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες + Background (Default) Φόντο (προεπιλογή) +# Batch / Image Processor + Include All Subfolders Συμπερίληψη όλων των υποφακέλων Auto Add/Delete Αυτόματη προσθήκη/διαγραφή # Native Windows printing qualification @@ -3842,3 +3853,25 @@ curves Smooth Εξομάλυνση Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Χρήση ενσωματωμένου προφίλ ({profile}) + Convert to the working space ({profile}) Μετατροπή στον χώρο εργασίας ({profile}) + Remember this choice for future images Απομνημόνευση αυτής της επιλογής για μελλοντικές εικόνες + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ενεργοποιήστε ξανά την ερώτηση με το Ask On Mismatch στο Επεξεργασία › Ρυθμίσεις χρωμάτων. + Button Mode Λειτουργία κουμπιών + Button Mode On Λειτουργία κουμπιών ενεργή + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Σκουρότερο περιβάλλον + Lighten Interface Φωτεινότερο περιβάλλον +# Bevel & Emboss Technique. + Technique Τεχνική + Chisel Hard Σκληρή σμίλευση + Chisel Soft Μαλακή σμίλευση + Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg +# Export As metadata and profile options + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών + Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b5db4e9925..e24171195a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -558,6 +558,10 @@ # Field Blur pins (#3373) No pin selected Ningún pin seleccionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo, el cuadrado para redondear las esquinas y los puntos interiores para fijar dónde empieza el desenfoque. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Arrastre el pin para mover la banda o su anillo para cambiar el desenfoque. Arrastre las líneas continuas para ensanchar la banda nítida, las discontinuas para fijar la transición y los puntos para girarla. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo; el anillo fija el ángulo de desenfoque. + Blur Angle Ángulo de desenfoque Iris Blur… Desenfoque de iris… Tilt-Shift… Cambio de inclinación… Path Blur… Desenfoque de trazado… @@ -986,7 +990,6 @@ Contiguous Contiguo Contrast Contraste Control: Control: - Convert document's colors to the working space Convertir los colores del documento al espacio de trabajo Convert to sRGB Convertir a sRGB Copies Ejemplares Copper Cobre @@ -1041,6 +1044,7 @@ Delete Group Eliminar grupo Delete Layer Mask Eliminar máscara de capa Delete Layers Eliminar capas + Add Shortcut Añadir método abreviado Delete Shortcut Eliminar método abreviado Delete Timeline Eliminar línea de tiempo Delete Tool Preset Eliminar ajuste preestablecido de herramienta @@ -1568,7 +1572,6 @@ Use Default Usar el predeterminado Use Legacy Usar la versión heredada Use Measurement Scale Usar la escala de medición - Use the embedded profile (instead of the working space) Usar el perfil incrustado (en lugar del espacio de trabajo) Variables bind a layer's visibility, text or pixels to a named data slot. Las variables vinculan la visibilidad, el texto o los píxeles de una capa a un espacio de datos con nombre. Vertical scale Escala vertical Vibrance Intensidad @@ -1896,6 +1899,8 @@ Camera Raw Defaults Valores predeterminados de Camera Raw Canvas border Borde del lienzo Show canvas dots Mostrar puntos del lienzo + Clip thumbnails to layer bounds Recortar miniaturas a los límites de la capa + Clip Thumbnails to Layer Bounds Recortar miniaturas a los límites de la capa Canvas color Color del lienzo Canvas custom color Color personalizado del lienzo Color picker Selector de color @@ -2086,7 +2091,8 @@ Expand group ({key}-click: all groups) Expandir grupo ({key}-clic: todos los grupos) Fade steps Pasos de desvanecimiento Hide the layer's effects ({key}-click: all layers) Ocultar los efectos de la capa ({key}-clic: todas las capas) - Import Brushes… (.abr) Importar pinceles… (.abr) + Import Brushes… (.abr or image) Importar pinceles… (.abr o imagen) + Import Brush from Image… Importar pincel desde imagen… Move Group Down Mover grupo abajo Move Group Up Mover grupo arriba Reset the brush to the defaults Restablecer el pincel a los valores predeterminados @@ -2200,7 +2206,6 @@ Cancelled opening {name} Se canceló la apertura de {name} The document “{name}” does not have an embedded {mode} profile. El documento «{name}» no tiene un perfil {mode} incrustado. The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual. - Embedded: {embedded} Incrustado: {embedded} Working: {working} Espacio de trabajo: {working} Developing {name} Revelando {name} Cancelled {label} Se canceló {label} @@ -2573,6 +2578,8 @@ fontWeight Italic Cursiva Color Picker (Layer Style Color) Selector de color (color del estilo de capa) Auto Hide Notifications Ocultar notificaciones automáticamente Notification Duration (seconds) Duración de la notificación (segundos) + Bundle Arrow-Key Nudges Agrupar desplazamientos con las flechas + Nudge Bundle Pause (ms) Pausa para agrupar desplazamientos (ms) Appearance Mode Modo de apariencia Dark Theme Tema oscuro Light Theme Tema claro @@ -2712,6 +2719,7 @@ cameraData Lens Objetivo Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan Loading fonts… Cargando fuentes… + Response curve Curva de respuesta Tool Herramienta Simulate Pressure Simular presión Stroke Path… Contornear trazado… @@ -2794,6 +2802,1283 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the +# other catalogs already translate +@id select.all Seleccionar todo + Delete Smart Filters Eliminar filtros inteligentes + Enable Smart Filters Activar filtros inteligentes + New Layer… Nueva capa… + Delete Layer… Eliminar capa… + Select Similar Colors Seleccionar colores similares + Canvas rotation Rotación del lienzo + Extensions Extensiones + Libraries Bibliotecas + 3D 3D + Toolbars Barras de herramientas + Wand Varita + Healing Corrección + Pen Pluma + Hand Mano + Sampler Muestreador + Magnetic Lasso Lazo magnético + Art History Brush Pincel histórico artístico + Spot Healing Corrección puntual + Patch Parche + Content-Aware Move Mover con detección de contenido + Perspective Crop Recorte con perspectiva + Dodge/Burn Sobreexponer/Subexponer + Dynamics Dinámica + Tilt Inclinación + Defaults restored. Valores predeterminados restaurados. + Stop Detener + Create new set Crear nuevo conjunto + Technology Previews Previsualizaciones de tecnología + Create new layer Crear nueva capa + Group · {n} layer Grupo · {n} capa + General Blend Fusión general + Advanced Blending Fusión avanzada + RGB RGB + Spot Channel Canal de tinta plana + Working Gray Plate Placa gris de trabajo + HSL/Grayscale HSL/Escala de grises + Tone Curve Curva de tonos + Split Toning División de tonos + Lens Corrections Correcciones de lente + Camera Calibration Calibración de cámara + Snapshots Instantáneas + Recovery Recuperación + Fill Light Luz de relleno + Convert to Grayscale Convertir a escala de grises + Sharpness Nitidez + Color Noise Reduction Reducción de ruido de color + Luminance Smoothness Suavizado de luminancia + Lens Objetivo + ISO ISO + Focal Length35mm Longitud focal equivalente (35 mm) + Aspect Ratio Relación de aspecto + Time Hora + Enter description… Introduzca una descripción… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard Estándar del sector + Uncoated CMYK CMYK sin estucar + Newspaper Papel de periódico + Working Spaces Espacios de trabajo + Color Management Policies Normas de gestión de color + Detect Faces Detectar caras + Localize Color Clusters Localizar grupos de color + Duplicated selection name Nombre de selección duplicado + Operation Operación + New channel only Solo canal nuevo + Color indicates El color indica + Create Snapshot Crear instantánea + Delete Snapshot Eliminar instantánea + New Snapshot Nueva instantánea + Snapshot 1 Instantánea 1 + Step Forward Avanzar un paso + Step Backward Retroceder un paso + Nonlinear History Historial no lineal + Set New Snapshot as Default Definir nueva instantánea como predeterminada + Automatically Create First Snapshot Crear automáticamente la primera instantánea + Automatically Create New Snapshot When Saving Crear automáticamente una instantánea nueva al guardar + Show New Snapshot Dialog by Default Mostrar de forma predeterminada el cuadro de diálogo Nueva instantánea + Clear History Borrar historial + Panel Options… Opciones de panel… + View menu Menú Vista + Keep view in sync Mantener la vista sincronizada + {zoom}% {zoom}% + Mouse Controls Controles del ratón + Initial Direction of Stroke Dirección inicial del trazo + Direction of Stroke Dirección del trazo + Humidity Humedad + Auto Clean Brush Limpiar el pincel automáticamente + Spacing: Espaciado: + New Layer from Brush Nueva capa a partir del pincel + New Brush Nuevo pincel + New Tool Preset Nuevo ajuste preestablecido de herramienta + Auto-Leading Interlineado automático + Scale Horizontally Escala horizontal + Scale Vertically Escala vertical + Mac LCD LCD de Mac + Mac Sharp Nítido de Mac + Mac Crisp Definido de Mac + Mac Strong Fuerte de Mac + Mac Smooth Suavizado de Mac + Superscript Superíndice + Subscript Subíndice + Ligature Ligadura + Alternative Alternativa + Ultra Light Ultrafina + Extra Light Extrafina + Semi Bold Seminegrita + Extra Bold Extranegrita + Ultra Bold Ultranegrita + Condensed Condensada + First Line Indent Sangría de primera línea + Hanging Punctuation Puntuación colgante + Roman Hanging Punctuation Puntuación latina colgante + Avoid Widows/Orphans Evitar líneas viudas y huérfanas + Compose Composición + Adobe Single-line Composer Compositor de línea única de Adobe + Adobe Every-line Composer Compositor multilínea de Adobe + Word Spacing Espacio entre palabras + Letter Spacing Espacio entre caracteres + Glyph Scaling Escala de glifos + Auto-leading % Interlineado automático % + Single Word Justification Justificación de una sola palabra + Justify Justificar + Glyph Glifo + Alternates for Current Selection Alternativas para la selección actual + Digits Dígitos + Path: Trazado: + Path to Illustrator… Trazado a Illustrator… + Clipping Path Trazado de recorte + New Path Nuevo trazado + Duplicate Path Duplicar trazado + Auto-Hide Ocultar automáticamente + X X + Y Y + Frame Sync Sincronización de fotogramas + Quick Sync Sincronización rápida + Audio Audio + Frame Duration Duración del fotograma + Loop Bucle + Split at Playhead Dividir en el cabezal de reproducción + Flatten Frames Into Clips Acoplar fotogramas en clips + Flatten Frames Into Layers Acoplar fotogramas en capas + Convert Frames > Video Layers Convertir fotogramas > Capas de vídeo + New Video Layer Nueva capa de vídeo + New Video Group Nuevo grupo de vídeo + Limit Playback Limitar reproducción + Enable Timeline Quick Config Activar configuración rápida de la línea de tiempo + Set Start Timecode… Establecer código de tiempo inicial… + Timecode Código de tiempo + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Tiempo actual + Out Salida + In Entrada + Duration Duración + Repeat Repetir + Play Once Reproducir una vez + Info Options… Opciones de información… + First Color Readout Primera lectura de color + Second Color Readout Segunda lectura de color + Actual Color Color real + Grayscale Slider Regulador de escala de grises + Web Color Color web + HSB Color Color HSB + Total Ink Tinta total + Opacity Mask Máscara de opacidad + Document Size Tamaño del documento + Timing Temporización + Status Information Información de estado + Saved Multichannel Multicanal guardado + Mouse Coordinates Coordenadas del ratón + Ruler Units Unidades de regla + Hide Ocultar + Restore last viewed comp Restaurar la última composición visualizada + Select All Layers Seleccionar todas las capas + Keep proportions Mantener proporciones + Interpolation Interpolación + Caption Pie de foto + Caption Credits Créditos del pie de foto + Keywords Palabras clave + Copyright Info URL URL de información de copyright + Copyright Status Estado del copyright + Copyrighted Con copyright + Public Domain Dominio público + Unknown Desconocido + Credit Crédito + Transmission Reference Referencia de transmisión + Urgency Urgencia + High Alta + Low Baja + Category Categoría + Supplemental Categories Categorías suplementarias + Object Name / Title Nombre del objeto / título + Date Created Fecha de creación + City Ciudad + Province / State Provincia / estado + Country País + Byline Autor + Byline Title Cargo del autor + Phone Teléfono + Email Correo electrónico + URLs URL + Last Modified Última modificación + EXIF EXIF + GPS GPS + Camera Model Modelo de cámara + Camera Make Fabricante de la cámara + EXIF Version Versión de EXIF + Exposure Program Programa de exposición + Aperture Abertura + Shutter Speed Velocidad de obturación + Metering Mode Modo de medición + Light Source Fuente de luz + Flash Flash + Lens Info Información del objetivo + ISO Speed Sensibilidad ISO + Subject Distance Distancia al sujeto + DateTime Original Fecha y hora originales + Create Date Fecha de creación + Modify Date Fecha de modificación + Raw Data Datos sin procesar + Import… Importar… + PDF Presentation… Presentación PDF… + Create Multiple Picture Packages Crear varios conjuntos de imágenes + Picture Package… Conjunto de imágenes… + Web Photo Gallery… Galería de fotografías web… + Choose… Elegir… + Select Folder… Seleccionar carpeta… + Override Action "Save" Commands Omitir comandos «Guardar» de la acción + File Naming Nombres de archivo + Compatibility Compatibilidad + Mac OS Mac OS + Unix Unix + Include ICC Profile Incluir perfil ICC + Error File Archivo de errores + Stop For Errors Detener si hay errores + Log Errors To File Registrar errores en un archivo + Errors Errores + Settings Configuración + Settings: Configuración: + Working Space Espacio de trabajo + RGB: RGB: + CMYK: CMYK: + Gray: Gris: + Spot: Tinta plana: + Advanced Controls Controles avanzados + Desaturate Monitor Colors By Desaturar colores del monitor un + Blend RGB Colors Using Gamma 1.0 Fusionar colores RGB con gamma 1,0 + Profile Mismatches: Ask When Opening Diferencias de perfil: preguntar al abrir + Ask When Pasting Preguntar al pegar + Missing Profiles: Ask When Opening Perfiles no disponibles: preguntar al abrir + Embed profiles Incrustar perfiles + Ask Before Saving Saved Embedded Preguntar antes de guardar el perfil incrustado + Coerce to sRGB Forzar sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB RGB de gama amplia + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic Minimalista + Expand All Groups Expandir todos los grupos + Photo (2 × 3 in) Foto (2 × 3 pulg.) + Photo (4 × 6 in) Foto (4 × 6 pulg.) + Photo (5 × 7 in) Foto (5 × 7 pulg.) + Photo (8 × 10 in) Foto (8 × 10 pulg.) + Recent Items Elementos recientes + Delete Preset… Eliminar ajuste preestablecido… + Preset Name: Nombre del ajuste preestablecido: + Width and Height Anchura y altura + Color Profile Perfil de color + Units Unidades + Create new count group Crear nuevo grupo de recuento + Adjustment Layer Capa de ajuste + Drag to move the layer Arrastre para mover la capa + Go to Layer Ir a la capa + After (X) Después (X) + Reference point location Ubicación del punto de referencia + Toggle reference point Mostrar u ocultar el punto de referencia + Add Pins Añadir chinchetas + Delete Pins Eliminar chinchetas + Rotate Auto Rotación automática + Rotate Fixed Rotación fija + Select Plane Seleccionar plano + Measure Medir + Heal Corrección + Heal Off Corrección desactivada + Heal On Corrección activada + Heal Auto Corrección automática + Blend Fusión + Brush Size Tamaño del pincel + Heal Color Color de corrección + Result Resultado + Duplicate Duplicar + Fast Rápida + Accurate Precisa + Add to Añadir a + Subtract from Restar de + Intersect with Formar intersección con + Capture Comp Capturar composición + No Layer Comps No hay composiciones de capas + Adobe PDF Presets… Ajustes preestablecidos de Adobe PDF… + 3D… 3D… + Technology Previews… Previsualizaciones de tecnología… + Add a mask (from the selection; ⌥ inverts) Añadir una máscara (a partir de la selección; ⌥ invierte) + Add to selection (⇧) Añadir a la selección (⇧) + Airbrush: keeps painting while the pointer rests. Aerógrafo: sigue pintando mientras el puntero permanece inmóvil. + Click the first point or press ↵ to close · Esc cancels Haga clic en el primer punto o pulse ↵ para cerrar · Esc cancela + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Haga clic para añadir un punto · arrástrelo fuera o use ⌘/Ctrl-clic o Supr para quitarlo + Click to apply, ⌥-click to clear overrides, double-click for Style Options Haga clic para aplicar, use ⌥-clic para borrar anulaciones y doble clic para Opciones de estilo + Click to zoom in · ⌥-click to zoom out Haga clic para acercar · use ⌥-clic para alejar + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic: esquina · Arrastrar: suavizar · Clic en primer punto: cerrar · ↩ terminar · Esc cancelar + Commit Perspective Warp (↩) Confirmar Deformación de perspectiva (↩) + Commit Puppet Warp (↩) Confirmar Deformación de posición libre (↩) + Commit any current edits (⌘↩) Confirmar las ediciones actuales (⌘↩) + Commit current crop operation (↵) Confirmar la operación de recorte actual (↵) + Commit transform (↩) Confirmar transformación (↩) + Commit warp (↩) Confirmar deformación (↩) + Create a new layer (⇧⌘N) Crear una capa nueva (⇧⌘N) + Custom pattern Motivo personalizado + General Brushes Pinceles generales + Grid (⌘') Cuadrícula (⌘') + Intersect with selection (⇧⌥) Formar intersección con la selección (⇧⌥) + New document… ⌘N Nuevo documento… ⌘N + Open… ⌘O Abrir… ⌘O + Rulers (⌘R) Reglas (⌘R) + Search commands (⌘K) Buscar comandos (⌘K) + Show Guides (⌘;) Mostrar guías (⌘;) + Subtract from selection (⌥) Restar de la selección (⌥) + Uses the foreground and background colors. Usa los colores frontal y de fondo. + ↑↓ navigate ↵ run esc close ↑↓ navegar ↵ ejecutar esc cerrar + Adds grain to the soft edges of the brush tip. Añade granulado a los bordes suaves de la punta del pincel. + Keeps the current texture when switching presets. Mantiene la textura actual al cambiar de ajuste preestablecido. + Watercolour look: lighter interior, darker rims. Aspecto de acuarela: interior más claro y bordes más oscuros. + Collapse group (⌥-click: all groups) Contraer grupo (⌥-clic: todos los grupos) + Expand group (⌥-click: all groups) Expandir grupo (⌥-clic: todos los grupos) + Hide the layer's effects (⌥-click: all layers) Ocultar los efectos de la capa (⌥-clic: todas las capas) + Show the layer's effects (⌥-click: all layers) Mostrar los efectos de la capa (⌥-clic: todas las capas) +cameraRaw Blacks Negros +cameraRaw Masking Enmascarado +@id edit.stroke Contornear +@id edit.transform.scale Escala +cameraRaw Luminance Luminancia +cameraRaw Color Color +cameraRaw Vibrance Intensidad +cameraRaw Color Grading Gradación de color +cameraRaw Global Global +cameraRaw Hue Tono +cameraRaw Luminance Detail Detalle de luminancia +cameraRaw Color Detail Detalle de color +cameraRaw Roughness Rugosidad +cameraRaw Whites Blancos +layerLabel No Color Sin color + Preferences… Preferencias… + Actual Pixels Píxeles reales + Clipping Mask Máscara de recorte + Tool Options Opciones de herramienta + Color Swatches Muestras de color + Linear Dodge Sobreexposición lineal + Preview Image Previsualizar imagen + Save As Guardar como + Remove All Eliminar todo + Unlock Desbloquear + Enable Activar + Disable Desactivar + On Activado + Clear Selection Borrar selección + Invert Selection Invertir selección + Select Inverse Seleccionar inverso + Contract Contraer + Sample Size Tamaño de muestra + Gamma Gamma + Input Entrada + Input Levels Niveles de entrada + Output Levels Niveles de salida + Layer Styles Estilos de capa + Blending Mode Modo de fusión + Stroke Width Anchura del trazo + Shadow Sombra + Adjustment Layers Capas de ajuste + Text Layer Capa de texto + Documents Documentos + File Name Nombre de archivo + File Format Formato de archivo + Compression Compresión + Artboards Mesas de trabajo + Snap to Grid Ajustar a cuadrícula + Full Screen Pantalla completa + Fullscreen Pantalla completa + Workspaces Espacios de trabajo + Show All Mostrar todo + Hidden Oculto + Duplicate Layer Duplicar capa + Ungroup Desagrupar + Guide Layout… Composición de guías… + Page Setup… Configurar página… + Reset Preferences Restablecer preferencias + Documentation Documentación + Check for Updates Buscar actualizaciones + Loading… Cargando… + Processing… Procesando… + Saving… Guardando… + Exporting… Exportando… + Please wait… Espere… + No selection Ninguna selección + No layers Ninguna capa + No recent files Ningún archivo reciente + Unsupported file format Formato de archivo no compatible + Failed to open file No se pudo abrir el archivo + Failed to save file No se pudo guardar el archivo + Operation canceled Operación cancelada + Changes have not been saved. Los cambios no se han guardado. + Are you sure? ¿Está seguro? + Discard Changes Descartar cambios + Open File Abrir archivo + Save File Guardar archivo + Save Changes Guardar cambios + Rendering… Procesando… + Importing… Importando… + Ready Listo + Failed Error + Warning Advertencia + Information Información + Retry Reintentar + Continue Continuar + Skip Omitir + Resume Reanudar + Reset All Restablecer todo + Defaults Valores predeterminados + Apply to All Aplicar a todo + Confirm Confirmar + Finish Finalizar + Back Atrás + Next Siguiente + Previous Anterior + More Más + Less Menos + Manual Manual + Disabled Desactivado + Locked Bloqueado + Unlocked Desbloqueado + Delete… Eliminar… + Create… Crear… + Edit… Editar… + Change… Cambiar… + Transformation Transformación + Scaling Escala + Canvas Size Tamaño de lienzo + Percentage Porcentaje + Degrees Grados + Middle Medio + Diameter Diámetro + Intensity Intensidad + Sensitivity Sensibilidad + Softness Suavidad + Feather Radius Radio de calado + Library Biblioteca + Layer Properties Propiedades de capa + Groups Grupos + Masks Máscaras + Add Mask Añadir máscara + Delete Mask Eliminar máscara + Invert Mask Invertir máscara + Enable Mask Activar máscara + Disable Mask Desactivar máscara + Apply Mask Aplicar máscara + Import Settings Importar ajustes + Export Settings Exportar ajustes + Shortcuts Métodos abreviados + Keyboard Teclado + Mouse Ratón + Tablet Tableta + Display Pantalla + Panel Panel + Hide Grid Ocultar cuadrícula + Fit to Window Ajustar a ventana + Live Preview Previsualización en vivo + Before Antes + After Después + Compare Comparar + File Size Tamaño de archivo + Filename Nombre de archivo + Open Folder Abrir carpeta + Choose Folder Elegir carpeta + Browse Examinar + Find Buscar + Replace Reemplazar + Date Fecha + Learn More Más información + Report a Bug Informar de un error + Updates Actualizaciones + Update Actualizar + Restart Required Es necesario reiniciar + Restart Reiniciar + Quit Salir + Quit PhotoCraft Salir de PhotoCraft + Close Window Cerrar ventana + New Window Nueva ventana + Open Image Abrir imagen + Save Image Guardar imagen + Export Image Exportar imagen + Import Image Importar imagen + Export As Exportar como + Export to Exportar a + Export Options Opciones de exportación + Import Options Opciones de importación + No Selection Ninguna selección + No Image Ninguna imagen + No Layers Ninguna capa + No Results Ningún resultado + No Results Found No se encontraron resultados + Nothing to Undo Nada que deshacer + Nothing to Redo Nada que rehacer + Changes Saved Cambios guardados + Save before closing? ¿Guardar antes de cerrar? + Are you sure you want to quit? ¿Está seguro de que desea salir? + The file could not be opened. No se pudo abrir el archivo. + The file could not be saved. No se pudo guardar el archivo. + Unsupported format Formato no compatible + Out of memory Memoria insuficiente + Permission denied Permiso denegado + File not found No se encontró el archivo + Operation failed Error en la operación + Operation completed Operación completada + Operation cancelled Operación cancelada + Sharpen Amount Cantidad de enfoque + Sharpen Radius Radio de enfoque + Sharpen Detail Detalle de enfoque + Sharpen Masking Enmascarado de enfoque + Noise Luminance Luminancia del ruido + Noise Luminance Detail Detalle de luminancia del ruido + Noise Color Color del ruido + Noise Color Detail Detalle de color del ruido + Grain Amount Cantidad de granulado + Grain Size Tamaño del granulado + Grain Roughness Rugosidad del granulado + Vignette Roundness Redondez de la viñeta + Vignette Feather Calado de la viñeta + Vignette Highlights Iluminaciones de la viñeta + Vignette Style Estilo de viñeta + Planes Planos + Plane Plano + Shadow Amount Cantidad de sombra + Shadow Tone Tono de sombra + Shadow Radius Radio de sombra + Highlight Amount Cantidad de iluminaciones + Highlight Tone Tono de iluminaciones + Highlight Radius Radio de iluminaciones + Midtone Medio tono + Black Clip Recorte negro + White Clip Recorte blanco + Fuzziness Tolerancia + Source Layer Capa de origen + Neutralize Neutralizar + Use Selection In Source Usar selección en origen + Use Selection In Target Usar selección en destino + Flame Type Tipo de llama + Randomize Length Aleatorizar longitud + Interval Intervalo + Adjust Interval For Loops Ajustar intervalo para bucles + Use Custom Color Usar color personalizado + Flame Lines Líneas de llama + Flame Bottom Alignment Alineación inferior de la llama + Flame Style Estilo de llama + Flame Shape Forma de llama + Randomize Shapes Aleatorizar formas + Frame Marco + Arrangement Disposición + Thickness Grosor + Vine Color Color de la enredadera + Flower Color Color de la flor + Leaf Color Color de las hojas + Base Tree Type Tipo de árbol base + Light Direction Dirección de la luz + Leaves Amount Cantidad de hojas + Leaves Size Tamaño de las hojas + Branches Height Altura de las ramas + Branches Thickness Grosor de las ramas + Default Leaves Hojas predeterminadas + Leaves Color Color de las hojas + Custom Branch Color Color personalizado de las ramas + Branches Color Color de las ramas + Flat Shading Sombreado plano + Date and Time Fecha y hora + Scale Units Unidades de escala + Scale Factor Factor de escala + Area Área + Perimeter Perímetro + Circularity Circularidad + Gray Value (Minimum) Valor de gris (mínimo) + Gray Value (Maximum) Valor de gris (máximo) + Gray Value (Mean) Valor de gris (media) + Gray Value (Median) Valor de gris (mediana) + Integrated Density Densidad integrada + Length Longitud + In Black Negro de entrada + In White Blanco de entrada + Out Black Negro de salida + Out White Blanco de salida + Tint Color Color de tinte + Stops Paradas + Center X Centro X + Center Y Centro Y + Cell Size Tamaño de celda + Generators Generadores + Wavelength Min Longitud de onda mínima + Wavelength Max Longitud de onda máxima + Amplitude Min Amplitud mínima + Amplitude Max Amplitud máxima + Undefined Areas Áreas no definidas + Preserve Conservar + Max Radius Radio máximo + Depth Mode Modo de profundidad + Solid Front Faces Caras frontales sólidas + Mask Incomplete Blocks Enmascarar bloques incompletos + Stylization Estilización + Cleanliness Limpieza + Bristle Detail Detalle de cerdas + Lighting Iluminación + Shine Brillo + Max Offset Desplazamiento máximo + Map Document Mapa del documento + Map Path Ruta del mapa + Map Layer Capa del mapa + Ridges Crestas + Light Type Tipo de luz + Light X Luz X + Light Y Luz Y + Light Z Luz Z + Target X Destino X + Target Y Destino Y + Hotspot Punto focal + Elevation Elevación + Ambient Ambiente + Warmth Calidez + Gloss Brillo + Metallic Metálico + Ambience Ambiente + White Is High El blanco es alto + Blade Curvature Curvatura de la hoja + Depth Map Mapa de profundidad + Focal Distance Distancia focal + Focus Enfoque + Transition Transición + Radius X Radio X + Radius Y Radio Y + Pins Chinchetas + Speed Velocidad + Taper Estrechamiento + Start X Inicio X + Start Y Inicio Y + End X Final X + End Y Final Y + Kernel Núcleo + Input Mode Modo de entrada + Row Order Orden de filas + Eliminate Eliminar + Create By Crear por + List Lista + Pencil Width Anchura del lápiz + Stroke Pressure Presión del trazo + Paper Brightness Brillo del papel + Number Of Levels Número de niveles + Edge Simplicity Simplicidad del borde + Edge Fidelity Fidelidad del borde + Brush Detail Detalle del pincel + Highlight Area Área de iluminaciones + Glow Size Tamaño del resplandor + Glow Brightness Brillo del resplandor + Brush Type Tipo de pincel + Stroke Size Tamaño del trazo + Stroke Detail Detalle del trazo + Highlight Strength Intensidad de iluminaciones + Smoothness Suavidad + Edge Thickness Grosor del borde + Edge Intensity Intensidad del borde + Posterization Posterización + Stroke Length Longitud del trazo + Relief Relieve + Definition Definición + Texture Coverage Cobertura de textura + Shadow Intensity Intensidad de sombras + Edge Width Anchura del borde + Edge Brightness Brillo del borde + Direction Balance Equilibrio de dirección + Black Intensity Intensidad de negros + White Intensity Intensidad de blancos + Dark Intensity Intensidad de oscuros + Custom Pixel Aspect Ratio Relación de aspecto de píxel personalizada + 32-bit Preview Options Opciones de vista previa de 32 bits + New Guide Nueva guía + Join the ArtCraft Discord Únase al Discord de ArtCraft + Report an Issue Informar de un problema + Load Files into Stack Cargar archivos en pila + Statistics Estadísticas + Duplicate Layers Duplicar capas + Edit Toolbar Editar barra de herramientas + Import Brushes Importar pinceles + Load 3D LUT Cargar LUT 3D + Press keys Pulse las teclas + Rename Group Cambiar nombre del grupo + Rename Tool Preset Cambiar nombre del ajuste preestablecido + Search commands, tools and panels Buscar comandos, herramientas y paneles + Slice Options Opciones de sector + Rename Brush Cambiar el nombre del pincel + Opening Abriendo + Reading Leyendo + Working Procesando + Accented Edges Bordes acentuados + Angled Strokes Trazos angulares + Anisotropic Anisotrópico + Around Center Alrededor del centro + Artistic Artístico + Bamboo Bambú + Bas Relief Bajorrelieve + Berries Bayas + Blast Ráfaga + Bleach Bypass Omisión de blanqueado + Blocks Bloques + Brick Ladrillo + Brush Strokes Trazos de pincel + Burlap Arpillera + Candle Light Luz de vela + Circle Círculo + Clumped Agrupado + Coarse Dots Puntos gruesos + Colored Pencil Lápiz de color + Contrasty Contrastado + Cool Frío + Craquelure Cuarteado + Cross Cruz + Cross Process Proceso cruzado + Crosshatch Sombreado cruzado + Cutout Recortado + Daisies Margaritas + Dark Rough Oscuro rugoso + Dark Strokes Trazos oscuros + Darken Only Solo oscurecer + Day For Night Noche americana + Deep Blue Azul intenso + Deep Emerald Esmeralda intenso + Deep Red Rojo intenso + Deep Yellow Amarillo intenso + Dot Punto + Double Line Línea doble + Draft Borrador + Dry Brush Pincel seco + Duplication Duplicación + Edge Extension Extensión de bordes + Edge Only Solo borde + Enlarged Ampliado + Even Fields Campos pares + Faded Film Película desvanecida + Film Grain Granulado de película + Fine Fino + Fine Dots Puntos finos + Flat Plano + Fresco Fresco + From Left Desde la izquierda + From Right Desde la derecha + Frosted Escarchado + Generic Genérico + Glass Vidrio + Glowing Edges Bordes resplandecientes + Grainy Dots Puntos granulados + Hearts Corazones + Heptagon Heptágono + Hexagon Hexágono + Horizontal Only Solo horizontal + Infinite Infinito + Ink Outlines Contornos de tinta + Ivy Hiedra + Left Diagonal Diagonal izquierda + Level Based Basado en niveles + Light Rough Claro rugoso + Lighten Only Solo aclarar + Long Lines Líneas largas + Long Strokes Trazos largos + Lower Inferior + Medium Dots Puntos medianos + Medium Lines Líneas medianas + Medium Strokes Trazos medianos + Mono Contrast Contraste monocromo + Mosaic Tiles Azulejos de mosaico + Multiple Flames Along Path Varias llamas a lo largo del trazado + Multiple Flames One Direction Varias llamas en una dirección + Multiple Flames Path Directions Varias llamas en las direcciones del trazado + Multiple Flames Various Length Varias llamas de distinta longitud + Octagon Octágono + Odd Fields Campos impares + One Flame Along Path Una llama a lo largo del trazado + Out From Center Desde el centro hacia fuera + Oval Óvalo + Overlay Edge Borde superpuesto + Palette Knife Espátula + Parallel Paralelo + Patchwork Retales + Pentagon Pentágono + Point Punto + Pointed Puntiagudo + Pond Ripples Ondas de estanque + Pyramids Pirámides + Random Aleatorio + Right Diagonal Diagonal derecha + Rope Cuerda + Roses Rosas + Sandstone Arenisca + Scallops Festones + Sepia Sepia + Short Lines Líneas cortas + Short Strokes Trazos cortos + Simple Simple + Light Intensity Intensidad de la luz + Spray Radius Radio de rociado + Stroke Direction Dirección del trazo + Graininess Granulosidad + Glow Amount Cantidad de resplandor + Clear Amount Cantidad de transparencia + Distortion Distorsión + Ripple Size Tamaño de onda + Ripple Magnitude Magnitud de onda + Charcoal Area Área de carboncillo + Chalk Area Área de tiza + Charcoal Thickness Grosor del carboncillo + Light Dark Balance Equilibrio claro/oscuro + Foreground Level Nivel frontal + Background Level Nivel de fondo + Pattern Type Tipo de motivo + Image Balance Equilibrio de imagen + Darkness Oscuridad + Fiber Length Longitud de fibra + Crack Spacing Espaciado de grietas + Crack Depth Profundidad de grietas + Crack Brightness Brillo de grietas + Grain Type Tipo de granulado + Tile Size Tamaño de azulejo + Grout Width Anchura de junta + Lighten Grout Aclarar junta + Square Size Tamaño de cuadrado + Border Thickness Grosor del borde + Correct Distortion Corregir distorsión + Correct Vignette Corregir viñeta + Correct C A Corregir AC + Auto Scale Escala automática + Red Cyan Rojo/Cian + Blue Yellow Azul/Amarillo + Vignette Amount Cantidad de viñeta + Vignette Midpoint Punto medio de la viñeta + Constraints Restricciones + Curve Highlights Curva de iluminaciones + Curve Lights Curva de claros + Curve Darks Curva de oscuros + Curve Shadows Curva de sombras + Curve Splits Divisiones de curva + Point Curve Curva de puntos + Point Curve Red Curva de puntos roja + Point Curve Green Curva de puntos verde + Point Curve Blue Curva de puntos azul + Hsl Hue Tono HSL + Hsl Sat Saturación HSL + Hsl Lum Luminancia HSL + Grade Shadows Gradación de sombras + Grade Midtones Gradación de medios tonos + Grade Highlights Gradación de iluminaciones + Grade Global Gradación global + Grade Blending Fusión de gradación + Grade Balance Equilibrio de gradación + Sine Seno + Sketch Boceto + Snowflakes Copos de nieve + Sparkle Destellos + Spatter Salpicadura + Speckle Motas + Spin Giro + Sprinkles Espolvoreado + Squareness Cuadratura + Stagger Escalonado + Stained Glass Vidriera + Star Estrella + Stars Estrellas + Stippled Punteado + Stretch Estirar + Teal Orange Verde azulado y naranja + Tiny Lens Lente diminuta + To Center Hacia el centro + Unaltered Sin alterar + Underpainting Pintura base + Underwater Bajo el agua + Upper Superior + Vertical Only Solo vertical + Vine With Flowers Enredadera con flores + Vine With Leaves Enredadera con hojas + Violent Violento + Warm Cálido + Watercolor Acuarela + Waves Ondas + Wide Blurry Ancho borroso + Wide Sharp Ancho nítido + Wrap Envolver + Zigzag Zigzag + Wetness Humedad + AI Integrations Integraciones de IA + Add Noise Añadir ruido + Apply Data Set Aplicar conjunto de datos + Apply Image Aplicar imagen + Arbitrary Arbitrario + Artboard from Group Mesa de trabajo a partir de grupo + Artboard from Layers Mesa de trabajo a partir de capas + Artboards to Files Mesas de trabajo en archivos + Artboards to PDF Mesas de trabajo en PDF + Assign Profile Asignar perfil + At En + Auto-Align Layers Alinear capas automáticamente + Auto-Blend Layers Fusionar capas automáticamente + Batch Lote + Border Borde + Box Blur Desenfoque de cuadro + Calculations Cálculos + Camera Raw Camera Raw + Camera Raw Filter Filtro de Camera Raw + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Clone Clonar + Color Decontaminate Descontaminar color + Color Halftone Semitono de color + Color Lookup Tables Tablas de consulta de colores + Color Settings Ajustes de color + Color Table Tabla de colores + Conditional Mode Change Cambio de modo condicional + Content-Aware Fill Relleno según el contenido + Convert to Linked Convertir en vinculado + Convert to Profile Convertir a perfil + Crystallize Cristalizar + Data Datos + Data Sets as Files Conjuntos de datos en archivos + De-Interlace Desentrelazar + Define Brush Preset Definir ajuste preestablecido de pincel + Define Custom Shape Definir forma personalizada + Define Pattern Definir motivo + Defringe Eliminar halos + Diffuse Difundir + Diffuse Glow Resplandor difuso + Displace Desplazar + Do you want to save you made to “{name}” before quitting? ¿Desea guardar los cambios realizados en «{name}» antes de salir? + Dust & Scratches Polvo y arañazos + Export Contents Exportar contenido + Export Preferences Preferencias de exportación + Export/Import Presets Exportar/importar ajustes preestablecidos + Extrude Extruir + Fibers Fibras + Field Blur Desenfoque de campo + File Info Información del archivo + Filter Gallery Galería de filtros + Find and Replace Text Buscar y reemplazar texto + Fit Image Ajustar imagen + Flame Llama + Focus Area Área de enfoque + Gaussian Blur Desenfoque gaussiano + Global Light Luz global + Group from Layers Grupo a partir de capas + HDR Toning Tonificación HDR + High Pass Paso alto + Highlight Iluminación + Image Processor Procesador de imágenes + Install Plug-in Instalar plugin + Interpret Footage Interpretar material de archivo + Iris Blur Desenfoque de iris + Jag Dentado + Label Etiqueta + Layer Comps to Files Composiciones de capa en archivos + Layer Content Options Opciones de contenido de capa + Layer From Background Capa a partir del fondo + Layer from Background Capa a partir del fondo + Layers to Files Capas en archivos + Legacy Heredado + Lens Blur Desenfoque de lente + Lens Correction Corrección de lente + Lens Flare Destello + Lighting Effects Efectos de iluminación + Liquify Licuar + Lock Layers Bloquear capas + Lum Lum + Match Color Igualar color + Merge to HDR Pro Combinar en HDR Pro + Mezzotint Grabado + Mosaic Mosaico + Motion Blur Desenfoque de movimiento + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Arrastrar y soltar archivos de forma nativa aún no es compatible con Wayland. Use Archivo › Abrir o ejecute PhotoCraft mediante XWayland con `WAYLAND_DISPLAY= photocraft`. + Neon Glow Resplandor de neón + New Guide Layout Nuevo diseño de guías + New Video Layer from File Nueva capa de vídeo desde archivo + Ocean Ripple Ondas marinas + Oil Paint Pintura al óleo + Open As Abrir como + Package Empaquetar + Paint Daubs Pinceladas + Path Blur Desenfoque de trazado + Paths to Illustrator Trazados a Illustrator + Photomerge Photomerge + Picture Frame Marco + Pinch Pellizcar + Place Embedded Colocar incrustado + Place Linked Colocar vinculado + Place Scale Marker Colocar marcador de escala + Plastic Wrap Plastificado + Pointillize Puntillista + Polar Coordinates Coordenadas polares + Polar To Rectangular Polar a rectangular + Poster Edges Bordes de cartel + Preset Manager Gestor de ajustes preestablecidos + Profile Perfil + Radial Blur Desenfoque radial + Raw Defaults Valores predeterminados de Raw + Rectangular To Polar Rectangular a polar + Relink to File Volver a vincular al archivo + Render Video Procesar vídeo + Replace Color Reemplazar color + Replace Contents Reemplazar contenido + Replace Footage Reemplazar material de archivo + Resolve Missing Fonts Resolver fuentes que faltan + Ripple Ondulación + Rough Pastels Pasteles rugosos + Sat Sat + Save a Copy Guardar una copia + Save for Web (Legacy) Guardar para web (heredado) + Scale Effects Escalar efectos + Script Events Manager Administrador de eventos de secuencias de comandos + Search menus Buscar en menús + Select and Mask Seleccionar y aplicar máscara + Set Measurement Scale Definir escala de medida + Shadows/Highlights Sombras/iluminaciones + Shape Blur Desenfoque de forma + Shear Inclinación + Smart Blur Desenfoque inteligente + Smart Sharpen Enfoque suavizado + Smudge Stick Barra de difuminado + Solid Color Color sólido + Spherize Esferizar + Spin Blur Desenfoque de rotación + Spot Tinta plana + Sprayed Strokes Trazos con spray + Strokes Trazos + Sumi-e Sumi-e + Surface Blur Desenfoque de superficie + Texturizer Texturizar + Tiles Azulejos + Tilt-Shift Cambio de inclinación + Trace Contour Trazar contorno + Trap Reventado + Tree Árbol + Trim Cortar + Turbulent Turbulento + Twirl Remolino + Unsharp Mask Máscara de enfoque + Use File › Open to open an image. Use Archivo › Abrir para abrir una imagen. + Vanishing Point Punto de fuga + Variable Data Sets Conjuntos de datos variables + Video Frames to Layers Fotogramas de vídeo a capas + WIA Support Compatibilidad con WIA + Warp Text Deformar texto + Wind Viento + Vertical Type Tool Herramienta Texto vertical + About {app} Acerca de {app} + Hide {app} Ocultar {app} + Quit {app} Salir de {app} + Services Servicios + Hide Others Ocultar otros + Lut LUT + Cooling L B B Enfriamiento LBB + Cooling80 Enfriamiento 80 + Cooling82 Enfriamiento 82 + Hsb HSB + Hsl HSL + Movie Prime Película Prime + Prime105 Prime 105 + Prime35 Prime 35 + Warming L B A Calentamiento LBA + Warming81 Calentamiento 81 + Warming85 Calentamiento 85 +@id filter.sharpen.sharpen Enfocar + Good Buena + Best Óptima + Adjustment Properties Propiedades de ajuste + Default Foreground and Background Colors Colores frontal y de fondo predeterminados + Move Layer Mover capa + Rectangular Selection Selección rectangular + Reorder Layer Reordenar capa + Select Layer Seleccionar capa + Set Colors Definir colores + Switch Foreground and Background Colors Intercambiar colores frontal y de fondo + Target Destino +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Crear trazado de trabajo… + Create Vector Mask Crear máscara vectorial + Make Selection… Crear selección… + Fill Path… Rellenar trazado… + Clipping Path… Trazado de recorte… + Free Transform Path Transformación libre de trazado + Unite Shapes at Overlap Unir formas superpuestas + Subtract Shapes at Overlap Restar formas superpuestas + Copy Fill Copiar relleno + Copy Complete Stroke Copiar trazo completo + Paste Fill Pegar relleno + Paste Complete Stroke Pegar trazo completo + Make Symmetry Path Crear trazado de simetría + Disable Symmetry Path Desactivar trazado de simetría + Select Custom Color… Seleccionar color personalizado… + Color Picker (Custom Canvas Color) Selector de color (color de lienzo personalizado) + Duplicate Channel Duplicar canal + Delete Channel Eliminar canal + Rename Channel… Cambiar nombre del canal… + Merge Spot Channel Combinar canal de tinta plana + Convert to Alpha Channel Convertir en canal alfa + Exit Quick Mask Salir de Máscara rápida + New Channel… Nuevo canal… + New Spot Channel… Nuevo canal de tinta plana… + Split Channels Dividir canales + Merge Channels… Combinar canales… + Color Indicates Masked Areas El color indica áreas enmascaradas + Color Indicates Selected Areas El color indica áreas seleccionadas + ✓ Color Indicates Masked Areas ✓ El color indica áreas enmascaradas + ✓ Color Indicates Selected Areas ✓ El color indica áreas seleccionadas + Up Arriba + To Hasta + Shallow Superficial + Deep Profundo + Blend Interior Effects as Group Fusionar efectos interiores como un grupo + Blend Clipped Layers as Group Fusionar capas recortadas como un grupo + Transparency Shapes Layer Capa de formas de transparencia + Layer Mask Hides Effects La máscara de capa oculta los efectos + Vector Mask Hides Effects La máscara vectorial oculta los efectos + General Blending Fusión general + This Layer: Esta capa: + Underlying Layer: Capa subyacente: + Nothing to paste: the clipboard holds no image Nada que pegar: el portapapeles no contiene ninguna imagen + Preferences saved Preferencias guardadas + Invalid panel layout; restored the default dock Disposición de paneles no válida; se restauró el acoplamiento predeterminado + Preferences were reset: {error} Se restablecieron las preferencias: {error} + Recovered {name} Se recuperó {name} + Couldn't save preferences: {error}. Retrying… No se pudieron guardar las preferencias: {error}. Reintentando… + Autosave failed: {error} Error de guardado automático: {error} + Exported presets to {path} Se exportaron los ajustes preestablecidos a {path} + Ran script {name} Se ejecutó la secuencia de comandos {name} + Sent to the printer ({spooler}) Se envió a la impresora ({spooler}) + Printed to {path} Se imprimió en {path} + Exported {path} ({size}) Se exportó {path} ({size}) + Exported {path} Se exportó {path} + Added swatch {name} Se añadió la muestra {name} + Step {step} ({id}) failed: {error} Error en el paso {step} ({id}): {error} + Could not rename the action: {error} No se pudo cambiar el nombre de la acción: {error} + Action playback failed: {error} Error al reproducir la acción: {error} + Clone source set at {x}, {y} Origen de clonación definido en {x}, {y} + Color management: {error} Gestión de color: {error} + Opened {url} Se abrió {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes No hay ninguna forma personalizada «{name}»: elija una en la barra de opciones o en Ventana › Formas + Saved a copy as {path} Se guardó una copia como {path} + Exported contents to {path} Se exportó el contenido a {path} + {label} is not available: {reason} {label} no está disponible: {reason} + Saved {path}; image assets: {n} in {dir} Se guardó {path}; recursos de imagen: {n} en {dir} + {message} (notes: {n}; {warning}) {message} (notas: {n}; {warning}) + Preferences can't be saved No se pueden guardar las preferencias + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft seguirá intentándolo; hasta que el guardado se complete, los cambios de preferencias se perderán al cerrar. + GPU retry scheduled Reintento de GPU programado + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Guarde su trabajo y reinicie PhotoCraft para volver a intentar la aceleración por GPU. La interpretación por CPU seguirá activa durante esta sesión. + GPU device was lost; using the CPU renderer. Se perdió el dispositivo GPU; se usará el renderizador por CPU. + GPU error; using the CPU renderer. Error de GPU; se usará el renderizador por CPU. + Couldn't open {name}: {error} No se pudo abrir {name}: {error} + Toggle the Brush Settings panel Mostrar u ocultar el panel Ajustes de pincel + New Brush Preset Nuevo ajuste preestablecido de pincel + Grid view Vista de cuadrícula + List view Vista de lista + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink Desenlazar + Link Enlazar + Commit transform Confirmar transformación + Cancel transform Cancelar transformación + Scroll menu up Desplazar menú hacia arriba + 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 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) + Color Picker (Foreground Color) Selector de color (color frontal) + Adwaita (Light) Adwaita (claro) + Adwaita (Dark) Adwaita (oscuro) + Open Recent is unavailable on the web Abrir recientes no está disponible en la web + Graphics adapter: {adapter} Adaptador gráfico: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Controlador: {driver} + GPU backend preference: {backend} Preferencia de backend de GPU: {backend} + Selected at launch: {backend} Seleccionado al iniciar: {backend} + Image compositor: {processor} Compositor de imágenes: {processor} + Fallback: {reason} Alternativa: {reason} + This session: {reason} Esta sesión: {reason} + Window renderer: {renderer} Renderizador de ventanas: {renderer} + Software adapter Adaptador de software + Graphics adapter Adaptador gráfico + unknown type tipo desconocido + Platform: {os} {arch} Plataforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} se conservó la lectura anterior; la última relectura falló: {reason} + Monitor profile: {profile} Perfil del monitor: {profile} + Display profiles: {reason} Perfiles de pantalla: {reason} + Monitor profile in use on {display}: {profile} Perfil de monitor en uso en {display}: {profile} + Monitor profile in use: {profile} Perfil de monitor en uso: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. El lienzo se muestra como sRGB. Elija un perfil de monitor en Edición › Ajustes de color. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. El lienzo de {display} se muestra como sRGB. Elija un perfil de monitor en Edición › Ajustes de color. + Display profile not used Perfil de pantalla sin usar + Monitor profile on {display} (main window): {profile} Perfil de monitor en {display} (ventana principal): {profile} + Monitor profile on {display}: {profile} Perfil de monitor en {display}: {profile} + fallback alternativa + The document does not have an embedded colour profile. Working space: {working}. El documento no tiene un perfil de color incrustado. Espacio de trabajo: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} El documento tiene un perfil de color incrustado que no coincide con el espacio de trabajo.\nIncrustado: {embedded}\nTrabajo: {working} + Importing {what} from {file}… Importando {what} de {file}… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Se importó {n} pincel de {file}|Se importaron {n} pinceles de {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Se importó {n} degradado de {file}|Se importaron {n} degradados de {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Se importó {n} motivo de {file}|Se importaron {n} motivos de {file} + Data Set: {name} ({index}/{total}) Conjunto de datos: {name} ({index}/{total}) + (none) (ninguno) +navigator Zoom Zoom + Apply {option}: {state} Aplicar {option}: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. Este ajuste ({key}) se conserva como se importó y aún no puede editarse. + Join the ArtCraft Discord ({url}) Unirse al Discord de ArtCraft ({url}) + {index} of {total} {index} de {total} + {shortcut} assigned. {shortcut} asignado. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} ya está en uso por {commands} y se quitará al hacer clic en Aceptar. + The current gradient (pick one in Window › Gradients) El degradado actual (elija uno en Ventana › Degradados) + auto automático + Baseline Shift Desplazamiento de línea de base + Breeze Dark Breeze oscuro + Breeze Light Breeze claro + brushes pinceles + Color Picker Selector de color + Current Actual + Dark Gray Gris oscuro + Fill Color Color de relleno + gradients degradados + Graphic and web Gráficos y web + layer mask máscara de capa + Lock All Bloquear todo + Lock Image Pixels Bloquear píxeles de la imagen + Lock Position Bloquear posición + Lock Transparent Pixels Bloquear píxeles transparentes + manual manual + Medium Gray Gris medio + New document Documento nuevo + New Selection Selección nueva + No Document Sin documento + Note tool Herramienta Nota + off desactivado + on activado + Recent Files Archivos recientes + Rgb RGB + Shortcut Método abreviado + Show Rulers Mostrar reglas + Solarized Dark Solarized oscuro + sRGB sRGB + Stroke Color Color del trazo + unknown desconocido + Unsaved Changes Cambios sin guardar + vector mask máscara vectorial + ZigZag Zigzag + Show home recent files Mostrar archivos recientes en la pantalla de inicio Clear Brush Controls Borrar controles de pincel Reset All Locked Settings Restaurar todos los ajustes bloqueados Copy Texture to Other Tools Copiar textura en otras herramientas @@ -2808,7 +4093,6 @@ curves Smooth Suavizar @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos - File Naming Nombres de archivo Starting serial Número de serie inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insertar detención… @@ -2820,6 +4104,9 @@ curves Smooth Suavizar Action playback stopped Reproducción de la acción detenida Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones + Background (Default) Fondo (predeterminado) +# Batch / Image Processor + Include All Subfolders Incluir todas las subcarpetas Auto Add/Delete Añadir/eliminar automáticamente # Native Windows printing qualification @@ -2829,3 +4116,24 @@ curves Smooth Suavizar Sent to {printer} Enviado a {printer} set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usar el perfil incrustado ({profile}) + Convert to the working space ({profile}) Convertir al espacio de trabajo ({profile}) + Remember this choice for future images Recordar esta opción para próximas imágenes + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Vuelve a activar el aviso con Ask On Mismatch en Edición › Ajustes de color. + Button Mode Modo de botón + Button Mode On Modo de botón activado + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Oscurecer interfaz + Lighten Interface Aclarar interfaz +# Bevel & Emboss Technique. + Technique Técnica + Chisel Hard Cincelar duro + Chisel Soft Cincelar suave + Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg +# Export As metadata and profile options + +# View › Guides › right-click a guide + Edit Selected Guides Editar guías seleccionadas + Clear Selected Guides Borrar guías seleccionadas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ab0cca6f40..db40d97abf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -546,6 +546,10 @@ # Field Blur pins (#3373) No pin selected Aucune épingle sélectionnée Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Faites glisser les poignées du contour pour le redimensionner et le faire pivoter, le carré pour arrondir ses angles et les points intérieurs pour définir où commence le flou. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Faites glisser l’épingle pour déplacer la bande, ou son anneau pour modifier le flou. Faites glisser les lignes pleines pour élargir la bande nette, les lignes pointillées pour régler la transition et les points pour la faire pivoter. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Faites glisser les poignées du contour pour le redimensionner et le faire pivoter ; l’anneau règle l’angle de flou. + Blur Angle Angle de flou Iris Blur… Flou d'iris… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… @@ -972,7 +976,6 @@ Contiguous Contigu Contrast Contraste Control: Contrôle : - Convert document's colors to the working space Convertir les couleurs du document vers l'espace de travail Convert to sRGB Convertir en sRVB Copies Exemplaires Copper Cuivre @@ -1027,6 +1030,7 @@ Delete Group Supprimer le groupe Delete Layer Mask Supprimer le masque de calque Delete Layers Supprimer les calques + Add Shortcut Ajouter un raccourci Delete Shortcut Supprimer le raccourci Delete Timeline Supprimer le montage Delete Tool Preset Supprimer le préréglage d'outil @@ -1547,7 +1551,6 @@ Use Default Utiliser la valeur par défaut Use Legacy Utiliser l'ancienne version Use Measurement Scale Utiliser l'échelle de mesure - Use the embedded profile (instead of the working space) Utiliser le profil incorporé (au lieu de l'espace de travail) Variables bind a layer's visibility, text or pixels to a named data slot. Les variables lient la visibilité, le texte ou les pixels d'un calque à un emplacement de données nommé. Vertical scale Échelle verticale Vibrance Vibrance @@ -1865,6 +1868,8 @@ Camera Raw Defaults Valeurs par défaut de Camera Raw Canvas border Bordure du canevas Show canvas dots Afficher les points du canevas + Clip thumbnails to layer bounds Rogner les vignettes aux limites du calque + Clip Thumbnails to Layer Bounds Rogner les vignettes aux limites du calque Canvas color Couleur du canevas Canvas custom color Couleur personnalisée du canevas Color picker Sélecteur de couleurs @@ -2051,7 +2056,8 @@ Expand group ({key}-click: all groups) Développer le groupe ({key}-clic : tous les groupes) Fade steps Étapes d'estompage Hide the layer's effects ({key}-click: all layers) Masquer les effets du calque ({key}-clic : tous les calques) - Import Brushes… (.abr) Importer des pinceaux… (.abr) + Import Brushes… (.abr or image) Importer des pinceaux… (.abr ou image) + Import Brush from Image… Importer un pinceau depuis une image… Move Group Down Descendre le groupe Move Group Up Monter le groupe Reset the brush to the defaults Réinitialiser le pinceau aux valeurs par défaut @@ -2160,7 +2166,6 @@ Cancelled opening {name} Ouverture de {name} annulée The document “{name}” does not have an embedded {mode} profile. Le document « {name} » n'a pas de profil {mode} incorporé. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel. - Embedded: {embedded} Incorporé : {embedded} Working: {working} Espace de travail : {working} Developing {name} Développement de {name} Cancelled {label} {label} : annulé @@ -2921,6 +2926,8 @@ fontWeight Italic Italique Color Picker (Layer Style Color) Sélecteur de couleurs (couleur du style de calque) Auto Hide Notifications Masquer automatiquement les notifications Notification Duration (seconds) Durée de la notification (secondes) + Bundle Arrow-Key Nudges Regrouper les déplacements aux flèches + Nudge Bundle Pause (ms) Pause de regroupement des déplacements (ms) Appearance Mode Mode d’apparence Dark Theme Thème sombre Light Theme Thème clair @@ -3046,6 +3053,7 @@ cameraData Lens Objectif Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant Loading fonts… Chargement des polices… + Response curve Courbe de réponse Tool Outil Simulate Pressure Simuler la pression Stroke Path… Contourner le tracé… @@ -3111,6 +3119,7 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Show home recent files Afficher les fichiers récents sur l'écran d'accueil Clear Brush Controls Effacer les commandes de pinceau Reset All Locked Settings Réinitialiser tous les paramètres verrouillés Copy Texture to Other Tools Copier la texture dans d'autres outils @@ -3137,6 +3146,10 @@ curves Smooth Lisser Action playback stopped Exécution de l’action arrêtée Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue @plural {n} action|{n} actions {n} action|{n} actions + Background (Default) Arrière-plan (par défaut) + Content-Aware Fill Remplissage d'après le contenu +# Batch / Image Processor + Include All Subfolders Inclure tous les sous-dossiers Auto Add/Delete Ajout/suppr. auto # Native Windows printing qualification @@ -3146,3 +3159,23 @@ curves Smooth Lisser Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Utiliser le profil incorporé ({profile}) + Convert to the working space ({profile}) Convertir vers l'espace de travail ({profile}) + Remember this choice for future images Mémoriser ce choix pour les prochaines images + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Réactivez la question avec Ask On Mismatch dans Édition › Paramètres de couleur. + Button Mode Mode bouton + Button Mode On Mode bouton activé + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Assombrir l'interface + Lighten Interface Éclaircir l'interface +# Bevel & Emboss Technique. + Technique Technique + Chisel Hard Ciselage dur + Chisel Soft Ciselage doux + Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Modifier les repères sélectionnés + Clear Selected Guides Effacer les repères sélectionnés diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9a7283a5a1..0404b429ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -563,6 +563,9 @@ Field Blur… Mezőelmosás… No pin selected Nincs kijelölt tű Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kattints a képre tű hozzáadásához; húzd a tűt az áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. A Delete törli a kijelölt tűt. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Húzd a körvonal fogantyúit az átméretezéshez és az elforgatáshoz, a négyzetet a sarkok lekerekítéséhez, a belső pontokat pedig annak beállításához, hol kezdődjön az elmosás. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Húzd a tűt a sáv áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. Húzd a folytonos vonalakat az éles sáv szélesítéséhez, a szaggatott vonalakat az átmenet beállításához, a pontokat pedig a sáv elforgatásához. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Húzd a körvonal fogantyúit az átméretezéshez és az elforgatáshoz; a gyűrű az elmosás szögét állítja be. Fill Path… Útvonal kitöltése… Fill Screen Képernyő kitöltése Filter Szűrő @@ -606,6 +609,7 @@ HSB/HSL HSB/HSL Huge Óriási Image Processor Képfeldolgozó + Include All Subfolders Az összes almappa belefoglalása Indexed Color… Indexelt szín… Info Információ Install Plug-in Bővítmény telepítése @@ -2488,7 +2492,6 @@ navigator Zoom Nagyítás mértéke Cancelled opening {name} Megnyitás megszakítva: {name} The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. - Embedded: {embedded} Beágyazott: {embedded} Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság @@ -2702,7 +2705,6 @@ cameraRaw Yellow Sárga Contents: Tartalom: Contributors Közreműködők Contributors: {contributors} · Commits: {commits} Közreműködők: {contributors} · Commitok: {commits} - Convert document's colors to the working space A dokumentum színeinek átalakítása a munkaszíntérbe Convert to sRGB Átalakítás sRGB-re Cool Hideg Copies Példányszám @@ -2731,6 +2733,7 @@ cameraRaw Yellow Sárga Defaults Alapértékek Delete Törlés Delete Shortcut Billentyűparancs törlése + Add Shortcut Billentyűparancs hozzáadása Description Leírás Diffusion Diffúzió DirectX 12 DirectX 12 @@ -2855,6 +2858,8 @@ cameraRaw Yellow Sárga No saved presets yet. Még nincs mentett előbeállítás. Not a web colour: click for the nearest web colour Nem webes szín: kattints a legközelebbi webes színért Notification Duration (seconds) Értesítés időtartama (másodperc) + Bundle Arrow-Key Nudges Nyílbillentyűs mozgatások összevonása + Nudge Bundle Pause (ms) Összevonási szünet (ms) Number Szám OK OK Only Web Colors Csak webes színek @@ -3001,7 +3006,6 @@ cameraRaw Yellow Sárga URLs URL-ek Use Default Alapértelmezés használata Use shift key for tool switch Eszközváltás a Shift billentyűvel - Use the embedded profile (instead of the working space) A beágyazott profil használata (a munkaszíntér helyett) Username Felhasználónév Version {version} Verzió: {version} Vulkan Vulkan @@ -3126,6 +3130,7 @@ cameraRaw Yellow Sárga Concise Tömör Constrain proportions Arányok megtartása Constraints Megkötések + Background (Default) Háttér (alapértelmezett) Content-Aware Fill Tartalomérzékeny kitöltés Content-Aware Move Tool Tartalomérzékeny mozgatás Contract Szűkítés @@ -3536,6 +3541,8 @@ cameraRaw Yellow Sárga shortcut billentyűparancs Shortcut Billentyűparancs Show canvas dots Vászonpontok megjelenítése + Clip thumbnails to layer bounds Bélyegképek levágása a réteg határaira + Clip Thumbnails to Layer Bounds Bélyegképek levágása a réteg határaira Show channels in color Csatornák megjelenítése színesen Show crosshair in brush tip Szálkereszt az ecsethegyben Show extension panels Bővítménypanelek megjelenítése @@ -4011,7 +4018,9 @@ cameraRaw Yellow Sárga The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív - Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra + Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + Response curve Válaszgörbe + Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn Clear Brush Controls Ecsetvezérlők törlése Reset All Locked Settings Összes zárolt beállítás visszaállítása Copy Texture to Other Tools Textúra másolása más eszközökre @@ -4065,3 +4074,23 @@ cameraRaw Yellow Sárga Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) A beágyazott profil használata ({profile}) + Convert to the working space ({profile}) Átalakítás a munkaszíntérbe ({profile}) + Remember this choice for future images A választás megjegyzése a későbbi képekhez + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdés a Szerkesztés › Színbeállítások Ask On Mismatch beállításával kapcsolható vissza. + Button Mode Gomb mód + Button Mode On Gomb mód be + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Felület sötétítése + Lighten Interface Felület világosítása +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Kemény vésés + Chisel Soft Lágy vésés + Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként + +# View › Guides › right-click a guide + Edit Selected Guides Kijelölt segédvonalak szerkesztése + Clear Selected Guides Kijelölt segédvonalak törlése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6924c45609..544ead05a4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -539,6 +539,10 @@ # Field Blur pins (#3373) No pin selected Tidak ada pin yang dipilih Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Seret pegangan pada garis luar untuk mengubah ukuran dan memutarnya, kotak untuk membulatkan sudutnya, dan titik di dalam untuk menentukan di mana blur dimulai. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Seret pin untuk memindahkan pita, atau cincinnya untuk mengubah blur. Seret garis utuh untuk melebarkan pita tajam, garis putus-putus untuk mengatur transisi, dan titik untuk memutarnya. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Seret pegangan pada garis luar untuk mengubah ukuran dan memutarnya; cincin mengatur sudut blur. + Blur Angle Sudut Blur Iris Blur… Iris Blur… Tilt-Shift… Tilt-Shift… Path Blur… Path Blur… @@ -966,7 +970,6 @@ Contiguous Berdekatan Contrast Kontras Control: Kontrol: - Convert document's colors to the working space Konversi warna dokumen ke working space Convert to sRGB Konversi ke sRGB Copies Salinan Copper Tembaga @@ -1021,6 +1024,7 @@ Delete Group Hapus Grup Delete Layer Mask Hapus Mask Layer Delete Layers Hapus Layer + Add Shortcut Tambah Pintasan Delete Shortcut Hapus Pintasan Delete Timeline Hapus Timeline Delete Tool Preset Hapus Preset Alat @@ -1541,7 +1545,6 @@ Use Default Pakai Default Use Legacy Pakai Versi Lama Use Measurement Scale Pakai Skala Pengukuran - Use the embedded profile (instead of the working space) Pakai profil tertanam (bukan working space) Variables bind a layer's visibility, text or pixels to a named data slot. Variabel mengikat visibilitas, teks, atau piksel sebuah layer ke slot data bernama. Vertical scale Skala vertikal Vibrance Vibrance @@ -1859,6 +1862,8 @@ Camera Raw Defaults Default Camera Raw Canvas border Border kanvas Show canvas dots Tampilkan titik kanvas + Clip thumbnails to layer bounds Pangkas thumbnail ke batas layer + Clip Thumbnails to Layer Bounds Pangkas Thumbnail ke Batas Layer Canvas color Warna kanvas Canvas custom color Warna kustom kanvas Color picker Pemilih warna @@ -2045,7 +2050,8 @@ Expand group ({key}-click: all groups) Bentangkan grup ({key}-klik: semua grup) Fade steps Langkah pudar Hide the layer's effects ({key}-click: all layers) Sembunyikan efek layer ({key}-klik: semua layer) - Import Brushes… (.abr) Impor Kuas… (.abr) + Import Brushes… (.abr or image) Impor Kuas… (.abr atau gambar) + Import Brush from Image… Impor Kuas dari Gambar… Move Group Down Pindah Grup ke Bawah Move Group Up Pindah Grup ke Atas Reset the brush to the defaults Reset kuas ke default @@ -2153,7 +2159,6 @@ Cancelled opening {name} Pembukaan {name} dibatalkan The document “{name}” does not have an embedded {mode} profile. Dokumen “{name}” tidak memiliki profil {mode} tertanam. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini. - Embedded: {embedded} Tertanam: {embedded} Working: {working} Working space: {working} Developing {name} Mengembangkan {name} Cancelled {label} {label} dibatalkan @@ -2908,6 +2913,8 @@ fontWeight Italic Miring Color Picker (Layer Style Color) Pemilih Warna (Warna Gaya Lapisan) Auto Hide Notifications Sembunyikan notifikasi secara otomatis Notification Duration (seconds) Durasi notifikasi (detik) + Bundle Arrow-Key Nudges Gabungkan pergeseran tombol panah + Nudge Bundle Pause (ms) Jeda penggabungan pergeseran (ms) Appearance Mode Mode tampilan Dark Theme Tema gelap Light Theme Tema terang @@ -3033,6 +3040,7 @@ cameraData Lens Lensa Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok Loading fonts… Memuat font… + Response curve Kurva respons Tool Alat Simulate Pressure Simulasikan Tekanan Stroke Path… Stroke Path… @@ -3098,6 +3106,7 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Show home recent files Tampilkan file terbaru di layar beranda Clear Brush Controls Hapus Kontrol Kuas Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci Copy Texture to Other Tools Salin Tekstur ke Alat Lain @@ -3124,6 +3133,10 @@ curves Smooth Haluskan Action playback stopped Pemutaran tindakan dihentikan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan + Background (Default) Background (Default) + Content-Aware Fill Isi Content-Aware +# Batch / Image Processor + Include All Subfolders Sertakan semua subfolder Auto Add/Delete Tambah/Hapus Otomatis # Native Windows printing qualification @@ -3133,3 +3146,23 @@ curves Smooth Haluskan Sent to {printer} Dikirim ke {printer} set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Pakai profil tertanam ({profile}) + Convert to the working space ({profile}) Konversi ke working space ({profile}) + Remember this choice for future images Ingat pilihan ini untuk gambar berikutnya + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Aktifkan lagi pertanyaan ini lewat Ask On Mismatch di Edit › Pengaturan Warna. + Button Mode Mode tombol + Button Mode On Mode tombol aktif + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gelapkan Antarmuka + Lighten Interface Cerahkan Antarmuka +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Pahat Keras + Chisel Soft Pahat Lembut + Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Edit Panduan Terpilih + Clear Selected Guides Hapus Panduan Terpilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d42c550cc..40b468926e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -554,6 +554,9 @@ # Field Blur pins (#3373) No pin selected Nessuna puntina selezionata Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Trascina le maniglie sul contorno per ridimensionarlo e ruotarlo, il quadrato per arrotondarne gli angoli e i punti interni per stabilire dove inizia la sfocatura. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Trascina la puntina per spostare la banda o il suo anello per cambiare la sfocatura. Trascina le linee continue per allargare la banda nitida, quelle tratteggiate per la transizione e i punti per ruotarla. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Trascina le maniglie sul contorno per ridimensionarlo e ruotarlo; l’anello imposta l’angolo di sfocatura. Iris Blur… Sfocatura diaframma… Tilt-Shift… Miniatura… Path Blur… Sfocatura tracciato… @@ -982,7 +985,6 @@ Contiguous Contiguo Contrast Contrasto Control: Controllo: - Convert document's colors to the working space Converti colori documento in spazio di lavoro Convert to sRGB Converti in sRGB Copies Copie Copper Rame @@ -1039,6 +1041,7 @@ Delete Group Elimina gruppo Delete Layer Mask Elimina maschera di livello Delete Layers Elimina livelli + Add Shortcut Aggiungi scorciatoia Delete Shortcut Elimina scorciatoia Delete Timeline Elimina timeline Delete Tool Preset Elimina predefinito strumento @@ -1564,7 +1567,6 @@ Use Default Usa predefinito Use Legacy Usa legacy Use Measurement Scale Usa scala misurazione - Use the embedded profile (instead of the working space) Usa profilo incorporato (invece dello spazio di lavoro) Variables bind a layer's visibility, text or pixels to a named data slot. Le variabili collegano visibilità, testo o pixel di un livello a uno slot dati nominato. Vertical scale Scala verticale Vibrance Vividezza @@ -1891,6 +1893,8 @@ Camera Raw Defaults Predefiniti Camera Raw Canvas border Bordo quadro Show canvas dots Mostra punti del quadro + Clip thumbnails to layer bounds Ritaglia le miniature ai limiti del livello + Clip Thumbnails to Layer Bounds Ritaglia le miniature ai limiti del livello Canvas color Colore quadro Canvas custom color Colore personalizzato quadro Color picker Selettore colore @@ -2082,6 +2086,8 @@ Fade steps Passi dissolvenza Hide the layer's effects ({key}-click: all layers) Nascondi effetti livello ({key}-clic: tutti i livelli) Import Brushes… (.abr) Importa pennelli… (.abr) + Import Brushes… (.abr or image) Importa pennelli… (.abr o immagine) + Import Brush from Image… Importa pennello da immagine… Move Group Down Sposta gruppo giù Move Group Up Sposta gruppo su Reset the brush to the defaults Reimposta pennello ai predefiniti @@ -2196,7 +2202,6 @@ Cancelled opening {name} Apertura {name} annullata The document “{name}” does not have an embedded {mode} profile. Il documento “{name}” non ha un profilo {mode} incorporato. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente. - Embedded: {embedded} Incorporato: {embedded} Working: {working} Di lavoro: {working} Developing {name} Sviluppo di {name} Cancelled {label} {label} annullato @@ -2573,6 +2578,8 @@ fontWeight Italic Corsivo Color Picker (Layer Style Color) Selettore colore (colore dello stile livello) Auto Hide Notifications Nascondi automaticamente le notifiche Notification Duration (seconds) Durata della notifica (secondi) + Bundle Arrow-Key Nudges Raggruppa gli spostamenti con le frecce + Nudge Bundle Pause (ms) Pausa per raggruppare gli spostamenti (ms) Appearance Mode Modalità aspetto Dark Theme Tema scuro Light Theme Tema chiaro @@ -2700,6 +2707,7 @@ cameraData Lens Obiettivo Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente Loading fonts… Caricamento font… + Response curve Curva di risposta Tool Strumento Simulate Pressure Simula pressione Stroke Path… Traccia tracciato… @@ -4072,6 +4080,7 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Show home recent files Mostra i file recenti nella schermata iniziale Clear Brush Controls Cancella controlli pennello Reset All Locked Settings Ripristina tutte le impostazioni bloccate Copy Texture to Other Tools Copia texture su altri strumenti @@ -4098,6 +4107,9 @@ navigator Zoom Zoom Action playback stopped Esecuzione dell’azione interrotta Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni + Background (Default) Sfondo (predefinito) +# Batch / Image Processor + Include All Subfolders Includi tutte le sottocartelle Auto Add/Delete Aggiungi/elimina automaticamente # Native Windows printing qualification @@ -4107,3 +4119,180 @@ navigator Zoom Zoom Sent to {printer} Inviato a {printer} set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usa profilo incorporato ({profile}) + Convert to the working space ({profile}) Converti in spazio di lavoro ({profile}) + Remember this choice for future images Ricorda questa scelta per le prossime immagini + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Riattiva la richiesta con Ask On Mismatch in Modifica › Impostazioni colore. + Button Mode Modalità pulsante + Button Mode On Modalità pulsante attiva +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Scurisci interfaccia + Lighten Interface Schiarisci interfaccia +# Bevel & Emboss Technique. + Technique Tecnica + Chisel Hard Cesello duro + Chisel Soft Cesello morbido + Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg + +# Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added + Adobe-rgb-compat Adobe RGB (compatibile) + All shortcuts reset to Photoshop defaults. Tutte le scorciatoie ripristinate ai predefiniti di Photoshop. + Arc Lower Arco inferiore + Arc Upper Arco superiore + Arrows Frecce + Ask On Mismatch Chiedi se il profilo non corrisponde + Ask On Missing Chiedi se manca il profilo + Ask On Paste Chiedi quando incolli + Based On Basato su + Basic Latin Latino di base + Bend Curvatura + Blend Text Gamma Gamma per la fusione del testo + Bloat Gonfiamento + Bpc Compensazione punto nero + Coated-cmyk CMYK patinato + Convert Converti + Currency Valute + Cyrillic Cirillico + Default Photoshop Size Dimensione predefinita di Photoshop + Display-p3 Display P3 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Trascina un riquadro ritaglio · trascina dentro per spostare · bordi ridimensionano ({ratio} proporzione, {centre} centro) · Spazio sposta durante disegno · {commit} conferma · Esc annulla + Drag: draw freehand · Release on the start point: close Trascina: disegna a mano libera · Rilascia sul punto iniziale: chiudi + Forward Warp Altera in avanti + Freeze Lasso Lazo congela + Freeze Mask Congela maschera + Gray-gamma-2.2 Grigio gamma 2,2 + Greek Greco + Horizontal Distortion Distorsione orizzontale + Import Photoshop brushes (.abr) Importa pennelli di Photoshop (.abr) + Intent Intento + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Crenatura: metrica, ottica o un valore in 1/1000 em (Alt+←/→ al cursore) + Latin 1 Latino 1 + Latin Extended-A Latino esteso A + Latin Extended-B Latino esteso B + Linear-srgb sRGB lineare + Mask Channel Canale maschera + Mask None Nessuna maschera + Math Symbols Simboli matematici + Monitor Profile Profilo monitor + Numbers Numeri + Pick Color From Image Preleva colore dall'immagine + Policy Cmyk Criterio CMYK + Policy Gray Criterio grigio + Policy Rgb Criterio RGB + Prophoto-compat ProPhoto RGB (compatibile) + Pucker Piega + Push Left Spingi a sinistra + R g b RGB + Rec2020 Rec. 2020 + Reconstruct All Ricostruisci tutto + Save Path Salva tracciato + Sgray sGray + Source Channel Canale sorgente + Source Invert Inverti sorgente + Source1 Channel Canale sorgente 1 + Source1 Invert Inverti sorgente 1 + Source2 Channel Canale sorgente 2 + Source2 Invert Inverti sorgente 2 + Temporary Temporanei + Thaw Mask Scongela maschera + Twirl Clockwise Vortice orario + Twirl Counterclockwise Vortice antiorario + Vertical Distortion Distorsione verticale + Working Cmyk CMYK di lavoro + Working Gray Grigio di lavoro + Working Rgb RGB di lavoro + blue blu + gray grigio + green verde + none nessuno + orange arancione + red rosso + violet viola + yellow giallo +# Shortcut commands, find and replace, profile prompts and metadata labels the German, Hungarian and Serbian catalogs added + Export As: {name} Esporta come: {name} + Color Sampler Tool Strumento Campionatore colore + Proof Setup… Configurazione prova… + Toggle Layer Mask Overlay Mostra/nascondi sovrapposizione maschera di livello + Increase Type Size Aumenta dimensione testo + Decrease Type Size Riduci dimensione testo + Increase Type Size by 10 Aumenta dimensione testo di 10 + Decrease Type Size by 10 Riduci dimensione testo di 10 + Make Selection from Path Crea selezione dal tracciato + Target Composite Channel Seleziona canale composito + Target Channel 3 Seleziona canale 3 + Target Channel 4 Seleziona canale 4 + Target Channel 5 Seleziona canale 5 + Target Channel 6 Seleziona canale 6 + Target Channel 7 Seleziona canale 7 + Target Channel 8 Seleziona canale 8 + Target Channel 9 Seleziona canale 9 + Fill with Foreground Color Riempi con colore primo piano + Fill with Background Color Riempi con colore sfondo + Fill with Foreground Color, Preserve Transparency Riempi con colore primo piano, conserva trasparenza + Fill with Background Color, Preserve Transparency Riempi con colore sfondo, conserva trasparenza + Decrease Brush Size Riduci dimensione pennello + Increase Brush Size Aumenta dimensione pennello + Decrease Brush Hardness Riduci durezza pennello + Increase Brush Hardness Aumenta durezza pennello + Space Barra spaziatrice + Hand Tool (hold) Strumento Mano (tieni premuto) + Zoom In (hold, drag to scrub) Ingrandisci (tieni premuto, trascina per lo zoom continuo) + Zoom Out (hold) Riduci (tieni premuto) + Erase with Brush or Pencil (hold) Cancella con Pennello o Matita (tieni premuto) + Author Title Qualifica autore + Copyright Url URL copyright + Title Titolo + Working-cmyk CMYK di lavoro + Simulate Paper Simula carta + Warming Filter (85) Filtro caldo (85) + Cw Orario + Ccw Antiorario + Nearest Vicino più prossimo + R G B RGB + W: L: + A: A: + Pattern Fill… Riempimento pattern… + Any Qualsiasi + Cmyk CMYK + Dont Enlarge Non ingrandire + Change Find Cambia e trova + Case Sensitive Maiuscole/minuscole + Whole Word Parola intera + Forward Avanti + Checkerboard Scacchiera + The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working} +@id edit.clear Cancella +@id file.close Chiudi +@id layer.layerMask.apply Applica +cameraRaw Balance Bilanciamento +cameraRaw Clarity Chiarezza +cameraRaw Color Mixer Miscelatore colore +cameraRaw Contrast Contrasto +cameraRaw Curve Curva +cameraRaw Dehaze Elimina foschia +cameraRaw Detail Dettaglio +cameraRaw Effects Effetti +cameraRaw Exposure Esposizione +cameraRaw Feather Sfumatura +cameraRaw Highlights Luci +cameraRaw Midpoint Punto medio +cameraRaw Midtones Mezzi toni +cameraRaw Radius Raggio +cameraRaw Roundness Rotondità +cameraRaw Saturation Saturazione +cameraRaw Shadows Ombre +cameraRaw Size Dimensione +cameraRaw Texture Texture +colorRange Invert Inverti +document Orientation Orientamento +layerLabel Magenta Magenta +layerLabel Red Rosso +layers Fill Riempimento +layers Fill: Riempimento: +shape Appearance Aspetto +# View › Guides › right-click a guide + Edit Selected Guides Modifica guide selezionate + Clear Selected Guides Cancella guide selezionate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e05f27d718..1d64bf868c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -556,6 +556,9 @@ # Field Blur pins (#3373) No pin selected ピンが選択されていません Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 輪郭上のハンドルをドラッグしてサイズ変更と回転、四角形で角の丸み、内側の点でぼかしの開始位置を設定します。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. ピンをドラッグして帯を移動し、リングでぼかしを変更します。実線で鮮明な帯の幅、破線で移行範囲、点で回転を調整します。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 輪郭上のハンドルをドラッグしてサイズ変更と回転を行います。リングでぼかしの角度を設定します。 Iris Blur… 虹彩絞りぼかし… Tilt-Shift… チルトシフト… Path Blur… パスぼかし… @@ -985,7 +988,6 @@ Contiguous 隣接 Contrast コントラスト Control: コントロール: - Convert document's colors to the working space ドキュメントの色を作業用スペースに変換 Convert to sRGB sRGB に変換 Copies 部数 Copper 銅 @@ -1040,6 +1042,7 @@ Delete Group グループを削除 Delete Layer Mask レイヤーマスクを削除 Delete Layers レイヤーを削除 + Add Shortcut ショートカットを追加 Delete Shortcut ショートカットを削除 Delete Timeline タイムラインを削除 Delete Tool Preset ツールプリセットを削除 @@ -1565,7 +1568,6 @@ Use Default 既定を使用 Use Legacy 従来方式を使用 Use Measurement Scale 測定スケールを使用 - Use the embedded profile (instead of the working space) 埋め込みプロファイルを使用 (作業用スペースの代わり) Variables bind a layer's visibility, text or pixels to a named data slot. 変数は、レイヤーの表示状態・テキスト・ピクセルを、名前付きのデータ枠に結び付けます。 Vertical scale 垂直比率 Vibrance 自然な彩度 @@ -1893,6 +1895,8 @@ Camera Raw Defaults Camera Raw の既定値 Canvas border カンバスの境界 Show canvas dots カンバスのドットを表示 + Clip thumbnails to layer bounds サムネールをレイヤーの境界でクリップ + Clip Thumbnails to Layer Bounds サムネールをレイヤーの境界でクリップ Canvas color カンバスの色 Canvas custom color カンバスのカスタム色 Color picker カラーピッカー @@ -2084,6 +2088,8 @@ Fade steps フェードのステップ数 Hide the layer's effects ({key}-click: all layers) レイヤーの効果を隠す ({key}+クリック: すべてのレイヤー) Import Brushes… (.abr) ブラシを読み込み…(.abr) + Import Brushes… (.abr or image) ブラシを読み込み…(.abr または画像) + Import Brush from Image… 画像からブラシを読み込み… Move Group Down グループを下へ移動 Move Group Up グループを上へ移動 Reset the brush to the defaults ブラシを既定に戻す @@ -2194,7 +2200,6 @@ Cancelled opening {name} {name} を開くのをキャンセルしました The document “{name}” does not have an embedded {mode} profile. ドキュメント「{name}」には埋め込み {mode} プロファイルがありません。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。 - Embedded: {embedded} 埋め込み: {embedded} Working: {working} 作業用スペース: {working} Developing {name} {name} を現像中 Cancelled {label} {label} をキャンセルしました @@ -3517,6 +3522,8 @@ fontWeight Italic 斜体 Color Picker (Layer Style Color) カラーピッカー (レイヤースタイルの色) Auto Hide Notifications 通知を自動的に隠す Notification Duration (seconds) 通知の表示時間 (秒) + Bundle Arrow-Key Nudges 矢印キーの移動をまとめる + Nudge Bundle Pause (ms) 移動をまとめる間隔(ms) Appearance Mode 外観モード Dark Theme ダークテーマ Light Theme ライトテーマ @@ -3641,6 +3648,7 @@ cameraData Lens レンズ Search settings… 設定を検索… No matching settings 一致する設定はありません Loading fonts… フォントを読み込み中… + Response curve 応答カーブ Simulate Pressure 筆圧をシミュレート Stroke Path… パスのストローク… Background Eraser 背景消しゴム @@ -3718,7 +3726,8 @@ curves Smooth スムーズ No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) - Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n} (Enter で適用、Esc で取り消し) + Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Show home recent files ホーム画面に最近使ったファイルを表示 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… @@ -3742,6 +3751,9 @@ curves Smooth スムーズ Action playback stopped アクションの再生を停止しました Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 + Background (Default) 背景(既定) +# Batch / Image Processor + Include All Subfolders すべてのサブフォルダーを含める # Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan) Forward Warp 前方ワープ @@ -3821,3 +3833,21 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 埋め込みプロファイルを使用 ({profile}) + Convert to the working space ({profile}) 作業用スペースに変換 ({profile}) + Remember this choice for future images 今後の画像でもこの選択を使用 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 確認を再び表示するには、編集 › カラー設定 の Ask On Mismatch をオンにします。 + Button Mode ボタンモード + Button Mode On ボタンモード オン +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface インターフェイスを暗くする + Lighten Interface インターフェイスを明るくする +# Bevel & Emboss Technique. + Technique テクニック + Chisel Hard チゼルハード + Chisel Soft チゼルソフト + Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く +# View › Guides › right-click a guide + Edit Selected Guides 選択したガイドを編集 + Clear Selected Guides 選択したガイドを消去 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7de9f61f10..e76a3c9826 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -543,6 +543,9 @@ # Field Blur pins (#3373) No pin selected 선택한 핀 없음 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 윤곽선의 핸들을 드래그하여 크기를 바꾸고 회전하고, 사각형으로 모서리를 둥글게 하며, 안쪽 점으로 흐림이 시작되는 위치를 설정합니다. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 핀을 드래그하여 띠를 이동하고 링을 드래그하여 흐림 정도를 바꿉니다. 실선으로 선명한 띠를 넓히고, 점선으로 전환 범위를, 점으로 회전을 조정합니다. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 윤곽선의 핸들을 드래그하여 크기를 바꾸고 회전합니다. 링으로 흐림 각도를 설정합니다. Iris Blur… 조리개 흐림 효과… Tilt-Shift… 기울기-이동… Path Blur… 패스 흐림 효과… @@ -961,7 +964,6 @@ Contiguous 인접 Contrast 대비 Control: 제어: - Convert document's colors to the working space 문서 색상을 작업 색상 공간으로 변환 Convert to sRGB sRGB로 변환 Copies 부수 Copper 구리 @@ -1018,6 +1020,7 @@ Delete Group 그룹 삭제 Delete Layer Mask 레이어 마스크 삭제 Delete Layers 레이어 삭제 + Add Shortcut 단축키 추가 Delete Shortcut 단축키 삭제 Delete Timeline 타임라인 삭제 Delete Tool Preset 도구 사전 설정 삭제 @@ -1539,7 +1542,6 @@ Use Default 기본값 사용 Use Legacy 기존 방식 사용 Use Measurement Scale 측정 비율 사용 - Use the embedded profile (instead of the working space) 포함된 프로파일 사용(작업 색상 공간 대신) Variables bind a layer's visibility, text or pixels to a named data slot. 변수는 레이어의 표시 여부, 텍스트 또는 픽셀을 이름이 지정된 데이터 슬롯에 연결합니다. Vertical scale 세로 비율 Vibrance 활기 @@ -1859,6 +1861,8 @@ Camera Raw Defaults Camera Raw 기본값 Canvas border 캔버스 테두리 Show canvas dots 캔버스 점 표시 + Clip thumbnails to layer bounds 썸네일을 레이어 경계로 자르기 + Clip Thumbnails to Layer Bounds 썸네일을 레이어 경계로 자르기 Canvas color 캔버스 색상 Canvas custom color 캔버스 사용자 정의 색상 Color picker 색상 피커 @@ -2045,7 +2049,8 @@ Expand group ({key}-click: all groups) 그룹 펼치기 ({key} 키를 누른 채 클릭: 모든 그룹) Fade steps 약화 단계 Hide the layer's effects ({key}-click: all layers) 레이어 효과 숨기기 ({key} 키를 누른 채 클릭: 모든 레이어) - Import Brushes… (.abr) 브러시 가져오기… (.abr) + Import Brushes… (.abr or image) 브러시 가져오기… (.abr 또는 이미지) + Import Brush from Image… 이미지에서 브러시 가져오기… Move Group Down 그룹 아래로 이동 Move Group Up 그룹 위로 이동 Reset the brush to the defaults 브러시를 기본값으로 초기화 @@ -2155,7 +2160,6 @@ Cancelled opening {name} {name} 열기를 취소했습니다 The document “{name}” does not have an embedded {mode} profile. 문서 “{name}”에 포함된 {mode} 프로파일이 없습니다. The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다. - Embedded: {embedded} 포함됨: {embedded} Working: {working} 작업 색상 공간: {working} Developing {name} {name} 현상 중 Cancelled {label} {label} 작업을 취소했습니다 @@ -3083,6 +3087,8 @@ fontWeight Italic 기울임 Color Picker (Layer Style Color) 색상 피커 (레이어 스타일 색상) Auto Hide Notifications 알림 자동 숨기기 Notification Duration (seconds) 알림 지속 시간(초) + Bundle Arrow-Key Nudges 방향키 이동을 하나로 묶기 + Nudge Bundle Pause (ms) 이동 묶음 대기 시간(ms) Appearance Mode 화면 모드 Dark Theme 어두운 테마 Light Theme 밝은 테마 @@ -3215,6 +3221,7 @@ cameraData Lens 렌즈 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 Loading fonts… 글꼴 불러오는 중… + Response curve 응답 곡선 Simulate Pressure 압력 시뮬레이션 Stroke Path… 패스 획… Background Eraser 배경 지우개 @@ -3284,6 +3291,7 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Show home recent files 홈 화면에 최근 파일 표시 Clear Brush Controls 브러시 제어 지우기 Reset All Locked Settings 모든 잠긴 설정 재설정 Copy Texture to Other Tools 다른 도구로 텍스처 복사 @@ -3310,6 +3318,9 @@ curves Smooth 매끄럽게 Action playback stopped 액션 재생이 중지되었습니다 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 + Background (Default) 배경(기본값) +# Batch / Image Processor + Include All Subfolders 모든 하위 폴더 포함 Auto Add/Delete 자동 추가/삭제 # Native Windows printing qualification @@ -3319,3 +3330,26 @@ curves Smooth 매끄럽게 Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 포함된 프로파일 사용({profile}) + Convert to the working space ({profile}) 작업 색상 공간으로 변환({profile}) + Remember this choice for future images 이후 이미지에도 이 선택 기억 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 편집 › 색상 설정의 Ask On Mismatch로 이 확인을 다시 켤 수 있습니다. + Button Mode 단추 모드 + Button Mode On 단추 모드 켜짐 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 인터페이스 어둡게 + Lighten Interface 인터페이스 밝게 +# Bevel & Emboss Technique. + Technique 기법 + Chisel Hard 단단하게 조각 + Chisel Soft 부드럽게 조각 + Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기 +# Export As metadata and profile options + Color Profile 색상 프로필 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 선택한 안내선 편집 + Clear Selected Guides 선택한 안내선 지우기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4ed7d8a744..2c9416c707 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -546,6 +546,9 @@ # Field Blur pins (#3373) No pin selected Geen speld geselecteerd Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Sleep de grepen op de omtrek om hem te schalen en te draaien, het vierkantje om de hoeken af te ronden en de punten binnenin om in te stellen waar de vervaging begint. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Sleep de speld om de band te verplaatsen, of zijn ring om de vervaging te wijzigen. Sleep de doorgetrokken lijnen om de scherpe band te verbreden, de stippellijnen voor de overgang en de punten om hem te draaien. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Sleep de grepen op de omtrek om hem te schalen en te draaien; de ring stelt de vervagingshoek in. Iris Blur… Irisvervaging… Tilt-Shift… Tilt-shift… Path Blur… Padvervaging… @@ -964,7 +967,6 @@ Contiguous Aaneengesloten Contrast Contrast Control: Besturing: - Convert document's colors to the working space Kleuren van document omzetten naar de werkruimte Convert to sRGB Omzetten naar sRGB Copies Exemplaren Copper Koper @@ -1021,6 +1023,7 @@ Delete Group Groep verwijderen Delete Layer Mask Laagmasker verwijderen Delete Layers Lagen verwijderen + Add Shortcut Sneltoets toevoegen Delete Shortcut Sneltoets verwijderen Delete Timeline Tijdlijn verwijderen Delete Tool Preset Gereedschapsvoorinstelling verwijderen @@ -1545,7 +1548,6 @@ Use Default Standaard gebruiken Use Legacy Verouderde versie gebruiken Use Measurement Scale Meetschaal gebruiken - Use the embedded profile (instead of the working space) Het ingesloten profiel gebruiken (in plaats van de werkruimte) Variables bind a layer's visibility, text or pixels to a named data slot. Variabelen koppelen de zichtbaarheid, tekst of pixels van een laag aan een benoemd gegevensveld. Vertical scale Verticale schaal Vibrance Levendigheid @@ -1865,6 +1867,8 @@ Camera Raw Defaults Standaardwaarden Camera Raw Canvas border Canvasrand Show canvas dots Canvasstippen tonen + Clip thumbnails to layer bounds Miniaturen bijsnijden tot laaggrenzen + Clip Thumbnails to Layer Bounds Miniaturen bijsnijden tot laaggrenzen Canvas color Canvaskleur Canvas custom color Aangepaste canvaskleur Color picker Kleurkiezer @@ -2051,7 +2055,8 @@ Expand group ({key}-click: all groups) Groep uitvouwen ({key}-klik: alle groepen) Fade steps Afzwakstappen Hide the layer's effects ({key}-click: all layers) De effecten van de laag verbergen ({key}-klik: alle lagen) - Import Brushes… (.abr) Penselen importeren… (.abr) + Import Brushes… (.abr or image) Penselen importeren… (.abr of afbeelding) + Import Brush from Image… Penseel uit afbeelding importeren… Move Group Down Groep omlaag Move Group Up Groep omhoog Reset the brush to the defaults Het penseel terugzetten naar de standaardwaarden @@ -2162,7 +2167,6 @@ Cancelled opening {name} Openen van {name} geannuleerd The document “{name}” does not have an embedded {mode} profile. Het document “{name}” heeft geen ingesloten {mode}-profiel. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte. - Embedded: {embedded} Ingesloten: {embedded} Working: {working} Werkruimte: {working} Developing {name} {name} wordt ontwikkeld Cancelled {label} {label} geannuleerd @@ -3675,6 +3679,8 @@ cameraRaw Whites Witten Color Picker (Layer Style Color) Kleurkiezer (kleur van laagstijl) Auto Hide Notifications Meldingen automatisch verbergen Notification Duration (seconds) Duur van melding (seconden) + Bundle Arrow-Key Nudges Pijltjestoetsverschuivingen bundelen + Nudge Bundle Pause (ms) Pauze voor bundelen van verschuivingen (ms) Appearance Mode Weergavemodus Next Appearance Mode Volgende weergavemodus Sync with system Synchroniseren met systeem @@ -3786,6 +3792,7 @@ cameraData Lens Lens No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer Loading fonts… Lettertypen laden… + Response curve Responscurve Simulate Pressure Druk simuleren Stroke Path… Pad omlijnen… Background Eraser Achtergrondgum @@ -3869,6 +3876,7 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Show home recent files Recente bestanden tonen op het startscherm Clear Brush Controls Penseelbesturing wissen Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen @@ -3895,6 +3903,9 @@ curves Smooth Vloeiend maken Action playback stopped Afspelen van de handeling gestopt Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen + Background (Default) Achtergrond (standaard) +# Batch / Image Processor + Include All Subfolders Alle submappen opnemen Auto Add/Delete Automatisch toevoegen/verwijderen # Native Windows printing qualification @@ -3904,3 +3915,23 @@ curves Smooth Vloeiend maken Sent to {printer} Verzonden naar {printer} set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Het ingesloten profiel gebruiken ({profile}) + Convert to the working space ({profile}) Omzetten naar de werkruimte ({profile}) + Remember this choice for future images Deze keuze onthouden voor volgende afbeeldingen + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Zet de vraag weer aan met Ask On Mismatch in Bewerken › Kleurinstellingen. + Button Mode Knopmodus + Button Mode On Knopmodus aan + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Interface donkerder maken + Lighten Interface Interface lichter maken +# Bevel & Emboss Technique. + Technique Techniek + Chisel Hard Beitel hard + Chisel Soft Beitel zacht + Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen + +# View › Guides › right-click a guide + Edit Selected Guides Geselecteerde hulplijnen bewerken + Clear Selected Guides Geselecteerde hulplijnen wissen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a27cb4af8d..eee1e61a90 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -555,6 +555,9 @@ # Field Blur pins (#3373) No pin selected Nie zaznaczono pinezki Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Przeciągnij uchwyty na obrysie, aby zmienić rozmiar i obrócić kształt, kwadrat, aby zaokrąglić narożniki, a punkty wewnątrz, aby ustawić, gdzie zaczyna się rozmycie. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Przeciągnij pinezkę, aby przesunąć pas, lub jej pierścień, aby zmienić rozmycie. Przeciągnij linie ciągłe, aby poszerzyć ostry pas, przerywane, aby ustawić przejście, a punkty, aby go obrócić. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Przeciągnij uchwyty na obrysie, aby zmienić rozmiar i obrócić kształt; pierścień ustawia kąt rozmycia. Iris Blur… Rozmycie irysowe… Tilt-Shift… Tilt-shift… Path Blur… Rozmycie ścieżki… @@ -983,7 +986,6 @@ Contiguous Sąsiadujące Contrast Kontrast Control: Sterowanie: - Convert document's colors to the working space Konwertuj kolory dokumentu na przestrzeń roboczą Convert to sRGB Konwertuj na sRGB Copies Kopie Copper Miedź @@ -1040,6 +1042,7 @@ Delete Group Usuń grupę Delete Layer Mask Usuń maskę warstwy Delete Layers Usuń warstwy + Add Shortcut Dodaj skrót Delete Shortcut Usuń skrót Delete Timeline Usuń oś czasu Delete Tool Preset Usuń ustawienie wstępne narzędzia @@ -1561,7 +1564,6 @@ Use Default Użyj domyślnego Use Legacy Użyj starszej wersji Use Measurement Scale Użyj skali pomiaru - Use the embedded profile (instead of the working space) Użyj osadzonego profilu (zamiast przestrzeni roboczej) Variables bind a layer's visibility, text or pixels to a named data slot. Zmienne wiążą widoczność, tekst lub piksele warstwy z nazwanym polem danych. Vertical scale Skala pionowa Vibrance Jaskrawość @@ -1887,6 +1889,8 @@ Camera Raw Defaults Domyślne ustawienia Camera Raw Canvas border Obramowanie płótna Show canvas dots Pokaż kropki płótna + Clip thumbnails to layer bounds Przycinaj miniatury do granic warstwy + Clip Thumbnails to Layer Bounds Przycinaj miniatury do granic warstwy Canvas color Kolor płótna Canvas custom color Własny kolor płótna Color picker Próbnik kolorów @@ -2075,7 +2079,8 @@ Expand group ({key}-click: all groups) Rozwiń grupę (kliknięcie z {key}: wszystkie grupy) Fade steps Kroki zanikania Hide the layer's effects ({key}-click: all layers) Ukryj efekty warstwy (kliknięcie z {key}: wszystkie warstwy) - Import Brushes… (.abr) Importuj pędzle… (.abr) + Import Brushes… (.abr or image) Importuj pędzle… (.abr lub obraz) + Import Brush from Image… Importuj pędzel z obrazu… Move Group Down Przesuń grupę w dół Move Group Up Przesuń grupę w górę Reset the brush to the defaults Przywróć domyślne ustawienia pędzla @@ -2181,7 +2186,6 @@ Cancelled opening {name} Anulowano otwieranie {name} The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}. - Embedded: {embedded} Osadzony: {embedded} Working: {working} Przestrzeń robocza: {working} Cancelled {label} Anulowano: {label} Esc to cancel Esc, aby anulować @@ -3519,6 +3523,8 @@ cameraRaw Roughness Chropowatość Color Picker (Layer Style Color) Próbnik kolorów (kolor stylu warstwy) Auto Hide Notifications Automatycznie ukrywaj powiadomienia Notification Duration (seconds) Czas trwania powiadomienia (sekundy) + Bundle Arrow-Key Nudges Grupuj przesunięcia strzałkami + Nudge Bundle Pause (ms) Przerwa grupowania przesunięć (ms) Appearance Mode Tryb wyglądu Dark Theme Ciemny motyw Light Theme Jasny motyw @@ -3644,6 +3650,7 @@ cameraData Lens Obiektyw Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień Loading fonts… Wczytywanie czcionek… + Response curve Krzywa odpowiedzi Simulate Pressure Symuluj nacisk Stroke Path… Obrysuj ścieżkę… Background Eraser Gumka tła @@ -3707,6 +3714,7 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Show home recent files Pokaż ostatnie pliki na ekranie głównym Clear Brush Controls Wyczyść elementy sterujące pędzlem Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi @@ -3733,6 +3741,9 @@ curves Smooth Wygładź Action playback stopped Odtwarzanie operacji zatrzymane Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji + Background (Default) Tło (domyślne) +# Batch / Image Processor + Include All Subfolders Uwzględnij wszystkie podfoldery Auto Add/Delete Automatyczne dodawanie/usuwanie # Native Windows printing qualification @@ -3742,3 +3753,23 @@ curves Smooth Wygładź Sent to {printer} Wysłano do {printer} set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Użyj osadzonego profilu ({profile}) + Convert to the working space ({profile}) Konwertuj na przestrzeń roboczą ({profile}) + Remember this choice for future images Zapamiętaj ten wybór dla kolejnych obrazów + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pytanie można włączyć ponownie opcją Ask On Mismatch w Edycja › Ustawienia kolorów. + Button Mode Tryb przycisku + Button Mode On Tryb przycisku włączony + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Przyciemnij interfejs + Lighten Interface Rozjaśnij interfejs +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Dłuto twarde + Chisel Soft Dłuto miękkie + Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Edytuj zaznaczone prowadnice + Clear Selected Guides Usuń zaznaczone prowadnice diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0a64497704..3e10fe7dbb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -540,6 +540,9 @@ # Field Blur pins (#3373) No pin selected Nenhum alfinete selecionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arraste as alças do contorno para redimensionar e girar, o quadrado para arredondar os cantos e os pontos internos para definir onde o desfoque começa. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Arraste o alfinete para mover a faixa ou o anel para alterar o desfoque. Arraste as linhas contínuas para alargar a faixa nítida, as tracejadas para definir a transição e os pontos para girá-la. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Arraste as alças do contorno para redimensionar e girar; o anel define o ângulo de desfoque. Iris Blur… Desfoque de íris… Tilt-Shift… Tilt-shift… Path Blur… Desfoque de demarcador… @@ -966,7 +969,6 @@ Contiguous Contíguo Contrast Contraste Control: Controle: - Convert document's colors to the working space Converter as cores do documento para o espaço de trabalho Convert to sRGB Converter para sRGB Copies Cópias Copper Cobre @@ -1023,6 +1025,7 @@ Delete Group Excluir grupo Delete Layer Mask Excluir máscara de camada Delete Layers Excluir camadas + Add Shortcut Adicionar atalho Delete Shortcut Excluir atalho Delete Timeline Excluir linha do tempo Delete Tool Preset Excluir predefinição de ferramenta @@ -1543,7 +1546,6 @@ Use Default Usar padrão Use Legacy Usar legado Use Measurement Scale Usar escala de medida - Use the embedded profile (instead of the working space) Usar o perfil incorporado (em vez do espaço de trabalho) Variables bind a layer's visibility, text or pixels to a named data slot. As variáveis vinculam a visibilidade, o texto ou os pixels de uma camada a um campo de dados nomeado. Vertical scale Escala vertical Vibrance Vibração @@ -1860,6 +1862,8 @@ Camera Raw Defaults Padrões do Camera Raw Canvas border Borda da tela de pintura Show canvas dots Mostrar pontos da tela de pintura + Clip thumbnails to layer bounds Recortar miniaturas aos limites da camada + Clip Thumbnails to Layer Bounds Recortar miniaturas aos limites da camada Canvas color Cor da tela de pintura Canvas custom color Cor personalizada da tela de pintura Color picker Seletor de cores @@ -2046,7 +2050,8 @@ Expand group ({key}-click: all groups) Expandir grupo ({key}-clique: todos os grupos) Fade steps Etapas de atenuação Hide the layer's effects ({key}-click: all layers) Ocultar os efeitos da camada ({key}-clique: todas as camadas) - Import Brushes… (.abr) Importar pincéis… (.abr) + Import Brushes… (.abr or image) Importar pincéis… (.abr ou imagem) + Import Brush from Image… Importar pincel de imagem… Move Group Down Mover grupo para baixo Move Group Up Mover grupo para cima Reset the brush to the defaults Redefinir o pincel para os padrões @@ -2156,7 +2161,6 @@ Cancelled opening {name} Abertura de {name} cancelada The document “{name}” does not have an embedded {mode} profile. O documento “{name}” não tem um perfil {mode} incorporado. The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual. - Embedded: {embedded} Incorporado: {embedded} Working: {working} Espaço de trabalho: {working} Developing {name} Revelando {name} Cancelled {label} {label} cancelado @@ -2925,6 +2929,8 @@ fontWeight Italic Itálico Color Picker (Layer Style Color) Seletor de cores (cor do estilo de camada) Auto Hide Notifications Ocultar notificações automaticamente Notification Duration (seconds) Duração da notificação (segundos) + Bundle Arrow-Key Nudges Agrupar deslocamentos com as setas + Nudge Bundle Pause (ms) Pausa para agrupar deslocamentos (ms) Appearance Mode Modo de aparência Dark Theme Tema escuro Light Theme Tema claro @@ -3050,6 +3056,7 @@ cameraData Lens Lente Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada Loading fonts… Carregando fontes… + Response curve Curva de resposta Tool Ferramenta Simulate Pressure Simular pressão Stroke Path… Traçar demarcador… @@ -3899,6 +3906,7 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Show home recent files Mostrar arquivos recentes na tela inicial Clear Brush Controls Limpar controles de pincel Reset All Locked Settings Redefinir todas as configurações bloqueadas Copy Texture to Other Tools Copiar textura para outras ferramentas @@ -3925,6 +3933,9 @@ layerLabel No Color Sem cor Action playback stopped Reprodução da ação interrompida Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações + Background (Default) Plano de fundo (padrão) +# Batch / Image Processor + Include All Subfolders Incluir todas as subpastas Auto Add/Delete Adicionar/excluir automaticamente # Native Windows printing qualification @@ -3934,3 +3945,23 @@ layerLabel No Color Sem cor Sent to {printer} Enviado para {printer} set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usar o perfil incorporado ({profile}) + Convert to the working space ({profile}) Converter para o espaço de trabalho ({profile}) + Remember this choice for future images Lembrar esta escolha para as próximas imagens + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Reative a pergunta com Ask On Mismatch em Editar › Configurações de cores. + Button Mode Modo de botão + Button Mode On Modo de botão ativado + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Escurecer interface + Lighten Interface Clarear interface +# Bevel & Emboss Technique. + Technique Técnica + Chisel Hard Cinzel duro + Chisel Soft Cinzel suave + Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Editar guias selecionadas + Clear Selected Guides Limpar guias selecionadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2dbbf4003c..862788630b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -346,6 +346,10 @@ # Field Blur pins (#3373) No pin selected Булавка не выбрана Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетаскивайте маркеры на контуре, чтобы изменить размер и повернуть его, квадрат — чтобы скруглить углы, а точки внутри — чтобы задать, где начинается размытие. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Перетащите булавку, чтобы сдвинуть полосу, или ее кольцо, чтобы изменить размытие. Сплошные линии расширяют резкую полосу, пунктирные задают переход, а точки поворачивают ее. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Перетаскивайте маркеры на контуре, чтобы изменить размер и повернуть его; кольцо задает угол размытия. + Blur Angle Угол размытия Iris Blur… Диафрагменное размытие… Tilt-Shift… Наклон-сдвиг… Path Blur… Размытие по контуру… @@ -909,9 +913,7 @@ Newspaper Газетная бумага Working Spaces Рабочие пространства Color Management Policies Правила управления цветом - Convert document's colors to the working space Преобразовать цвета документа в рабочее пространство Assign working space Назначить рабочее пространство - Use the embedded profile (instead of the working space) Использовать встроенный профиль (вместо рабочего пространства) Leave as is (don't color manage) Оставить как есть (без управления цветом) Discard the embedded profile (don't color manage) Не использовать встроенный профиль (без управления цветом) Embed Color Profile Встроить цветовой профиль @@ -1038,6 +1040,8 @@ Rename Brush… Переименовать кисть… Delete Brush Удалить кисть Import Brushes… Импортировать кисти… + Import Brushes… (.abr or image) Импортировать кисти… (.abr или изображение) + Import Brush from Image… Импортировать кисть из изображения… Gradient Preset Options Параметры наборов градиентов Import .GRD Импортировать .GRD Choose a gradient file (.grd). Выберите файл градиентов (.grd). @@ -1696,6 +1700,7 @@ These settings aren't available in PhotoCraft yet. Эти параметры пока недоступны в PhotoCraft. Shortcut removed. Комбинация клавиш удалена. All shortcuts reset to PhotoCraft defaults. Все комбинации клавиш сброшены к стандартным PhotoCraft. + Add Shortcut Добавить комбинацию Delete Shortcut Удалить комбинацию Press keys… Нажмите клавиши… shortcut комбинация @@ -2304,6 +2309,8 @@ Camera Raw Defaults Значения Camera Raw по умолчанию Canvas border Рамка холста Show canvas dots Показывать точки холста + Clip thumbnails to layer bounds Обрезать миниатюры по границам слоя + Clip Thumbnails to Layer Bounds Обрезать миниатюры по границам слоя Canvas color Цвет холста Canvas custom color Свой цвет холста Classic Классическая @@ -2476,7 +2483,6 @@ Cancelled opening {name} Открытие {name} отменено The document “{name}” does not have an embedded {mode} profile. В документе «{name}» нет встроенного профиля {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}. - Embedded: {embedded} Встроенный: {embedded} Working: {working} Рабочее пространство: {working} Developing {name} Обработка {name} Cancelled {label} {label}: отменено @@ -2772,6 +2778,8 @@ fontWeight Italic Курсив Color Picker (Layer Style Color) Палитра цветов (цвет стиля слоя) Auto Hide Notifications Автоскрытие уведомлений Notification Duration (seconds) Длительность уведомления (секунды) + Bundle Arrow-Key Nudges Объединять сдвиги стрелками + Nudge Bundle Pause (ms) Пауза объединения сдвигов (мс) Appearance Mode Режим оформления Dark Theme Тёмная тема Light Theme Светлая тема @@ -2969,6 +2977,7 @@ cameraData Lens Объектив Search settings… Поиск настроек… No matching settings Нет подходящих настроек Loading fonts… Загрузка шрифтов… + Response curve Кривая отклика Tool Инструмент Simulate Pressure Имитировать нажим Stroke Path… Обвести контур… @@ -3055,6 +3064,7 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Show home recent files Показывать недавние файлы на домашнем экране Clear Brush Controls Очистить параметры кисти Reset All Locked Settings Сбросить все заблокированные параметры Copy Texture to Other Tools Скопировать текстуру в другие инструменты @@ -3081,6 +3091,10 @@ curves Smooth Сгладить Action playback stopped Воспроизведение операции остановлено Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций + Background (Default) Фон (по умолчанию) + Content-Aware Fill Заливка с учётом содержимого +# Batch / Image Processor + Include All Subfolders Включить все вложенные папки Auto Add/Delete Автодобавление/удаление # Native Windows printing qualification @@ -3110,3 +3124,23 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Использовать встроенный профиль ({profile}) + Convert to the working space ({profile}) Преобразовать в рабочее пространство ({profile}) + Remember this choice for future images Запомнить этот выбор для следующих изображений + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Снова включить вопрос можно параметром Ask On Mismatch в Редактирование › Настройки цвета. + Button Mode Режим кнопок + Button Mode On Режим кнопок включён + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Сделать интерфейс темнее + Lighten Interface Сделать интерфейс светлее +# Bevel & Emboss Technique. + Technique Метод + Chisel Hard Жесткая огранка + Chisel Soft Мягкая огранка + Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Изменить выбранные направляющие + Clear Selected Guides Удалить выбранные направляющие diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4a5f7459df..b91b67b3eb 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -535,6 +535,9 @@ Field Blur… Zamućenje polja… No pin selected Nijedna čioda nije izabrana Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite na sliku da dodate čiodu; prevucite čiodu da je pomerite ili njen prsten da promenite zamućenje. Delete uklanja izabranu čiodu. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Prevucite ručke na obrisu da promenite veličinu i rotirate ga, kvadrat da zaoblite uglove, a tačke unutra da odredite gde zamućenje počinje. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Prevucite čiodu da pomerite traku ili njen prsten da promenite zamućenje. Prevucite pune linije da proširite oštru traku, isprekidane linije da podesite prelaz, a tačke da je rotirate. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Prevucite ručke na obrisu da promenite veličinu i rotirate ga; prsten određuje ugao zamućenja. Iris Blur… Iris zamućenje… Tilt-Shift… Tilt-shift… Path Blur… Zamućenje duž putanje… @@ -954,7 +957,6 @@ Contiguous Susedno Contrast Kontrast Control: Kontrola: - Convert document's colors to the working space Pretvori boje dokumenta u radni prostor Convert to sRGB Pretvori u sRGB Copies Primerci Copper Bakar @@ -1009,6 +1011,7 @@ Delete Group Obriši grupu Delete Layer Mask Obriši masku sloja Delete Layers Obriši slojeve + Add Shortcut Dodaj prečicu Delete Shortcut Obriši prečicu Delete Timeline Obriši vremensku liniju Delete Tool Preset Obriši šablon alatke @@ -1526,7 +1529,6 @@ Use Default Koristi podrazumevano Use Legacy Koristi staro Use Measurement Scale Koristi razmeru merenja - Use the embedded profile (instead of the working space) Koristi ugrađeni profil (umesto radnog prostora) Variables bind a layer's visibility, text or pixels to a named data slot. Promenljive vezuju vidljivost, tekst ili piksele sloja za imenovano polje podataka. Vertical scale Vertikalna razmera Vibrance Živost @@ -1844,6 +1846,8 @@ Camera Raw Defaults Podrazumevana Camera Raw podešavanja Canvas border Ivica platna Show canvas dots Prikaži tačke na platnu + Clip thumbnails to layer bounds Iseci sličice prema granicama sloja + Clip Thumbnails to Layer Bounds Iseci sličice prema granicama sloja Canvas color Boja platna Canvas custom color Prilagođena boja platna Color picker Birač boja @@ -2139,7 +2143,6 @@ Cancelled opening {name} Otkazano otvaranje: {name} The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nema ugrađeni {mode} profil. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom. - Embedded: {embedded} Ugrađeni: {embedded} Working: {working} Radni prostor: {working} Developing {name} Razvijanje: {name} Cancelled {label} Otkazano: {label} @@ -2897,6 +2900,8 @@ fontWeight Italic Kurziv Color Picker (Layer Style Color) Birač boja (boja stila sloja) Auto Hide Notifications Automatski sakrij obaveštenja Notification Duration (seconds) Trajanje obaveštenja (sekunde) + Bundle Arrow-Key Nudges Objedini pomeranja strelicama + Nudge Bundle Pause (ms) Pauza za objedinjavanje pomeranja (ms) Appearance Mode Režim izgleda Presentation Prikaz Dark Theme Tamna tema @@ -3000,6 +3005,7 @@ cameraData Lens Objektiv Create a new rectangular frame Napravi novi pravougaoni okvir Create a new elliptical frame Napravi novi eliptični okvir Include in playback Uključi u reprodukciju + Include All Subfolders Uključi sve podfascikle Perspective Crop Tool Alatka za perspektivno opsecanje Show Grid Prikaži mrežu Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje @@ -3730,6 +3736,7 @@ layerLabel No Color Bez boje Color Settings Podešavanja boja Color Table Tabela boja Conditional Mode Change Uslovna promena režima + Background (Default) Pozadina (podrazumevano) Content-Aware Fill Popuna prema sadržaju Convert to Linked Pretvori u povezani Convert to Profile Pretvori u profil @@ -4219,3 +4226,25 @@ shape Appearance Izgled Ink Characteristics Karakteristike mastila Solidity: Punoća: Redrawing Ponovno iscrtavanje + Response curve Kriva odziva +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Koristi ugrađeni profil ({profile}) + Convert to the working space ({profile}) Pretvori u radni prostor ({profile}) + Remember this choice for future images Zapamti ovaj izbor za buduće slike + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pitanje ponovo uključite opcijom Ask On Mismatch u Uređivanje › Podešavanja boja. + Button Mode Režim dugmadi + Button Mode On Režim dugmadi uključen + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Zatamni interfejs + Lighten Interface Posvetli interfejs +# Bevel & Emboss Technique. + Technique Tehnika + Chisel Hard Tvrdo klesanje + Chisel Soft Meko klesanje + Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg + Show home recent files Prikaži nedavne datoteke na početnom ekranu + +# View › Guides › right-click a guide + Edit Selected Guides Uredi izabrane vodilje + Clear Selected Guides Ukloni izabrane vodilje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 01c37a18b2..4c8f06ccc1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -534,6 +534,9 @@ Field Blur… Alan Bulanıklığı… No pin selected Seçili pim yok Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Pim eklemek için görüntüye tıklayın; taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Delete seçili pimi siler. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Yeniden boyutlandırmak ve döndürmek için dış çizgideki tutamaçları, köşeleri yuvarlamak için kareyi, bulanıklığın nerede başlayacağını belirlemek için içerideki noktaları sürükleyin. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Bandı taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Keskin bandı genişletmek için düz çizgileri, geçişi ayarlamak için kesikli çizgileri, döndürmek için noktaları sürükleyin. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Yeniden boyutlandırmak ve döndürmek için dış çizgideki tutamaçları sürükleyin; halka bulanıklık açısını belirler. Iris Blur… İris Bulanıklığı… Tilt-Shift… Tilt-Shift… Path Blur… Yol Bulanıklığı… @@ -946,7 +949,6 @@ Contiguous Bitişik Contrast Kontrast Control: Kontrol: - Convert document's colors to the working space Belgenin renklerini çalışma alanına dönüştürün Convert to sRGB sRGB'ye dönüştür Copies Kopyalar Copper Bakır @@ -1004,6 +1006,7 @@ Delete Layer Mask Katman Maskesini Sil Delete Layers Katmanları Sil Delete Shortcut Kısayolu Sil + Add Shortcut Kısayol Ekle Delete Timeline Zaman Çizelgesini Sil Delete Tool Preset Araç Ön Ayarını Sil Delete count group Sayım grubunu sil @@ -1521,7 +1524,6 @@ Use Default Varsayılanı Kullan Use Legacy Eskiyi Kullan Use Measurement Scale Ölçüm Ölçeğini Kullan - Use the embedded profile (instead of the working space) Gömülü profili kullanın (çalışma alanı yerine) Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Vertical scale Dikey ölçek Vibrance Canlılık @@ -1841,6 +1843,8 @@ Camera Raw Defaults Camera Raw Varsayılanları Canvas border Kanvas kenarlığı Show canvas dots Kanvas noktalarını göster + Clip thumbnails to layer bounds Küçük resimleri katman sınırlarına kırp + Clip Thumbnails to Layer Bounds Küçük resimleri katman sınırlarına kırp Canvas color Kanvas rengi Canvas custom color Kanvas özel rengi Color picker Renk seçici @@ -2136,7 +2140,6 @@ Cancelled opening {name} {name} açılışı iptal edildi The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. - Embedded: {embedded} Gömülü: {embedded} Working: {working} Çalışma alanı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi @@ -3289,6 +3292,7 @@ cameraRaw Color Renk Compose Oluştur Condensed Yoğunlaştırılmış Conditional Mode Change Koşullu Mod Değişikliği + Background (Default) Arka plan (varsayılan) Content-Aware Fill İçeriğe Uygun Dolgu Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları Convert to Linked Bağlantılıya Dönüştür @@ -3358,6 +3362,7 @@ cameraRaw Color Renk How often fastening points are placed by themselves Sabitleme noktalarının kendi başlarına ne sıklıkla yerleştirildiği ISO Speed ISO Hızı Image Processor Görüntü İşlemcisi + Include All Subfolders Tüm Alt Klasörleri Dahil Et In içinde Install Plug-in Eklentiyi Yükle Interpret Footage Görüntüyü Yorumlama @@ -3642,6 +3647,8 @@ cameraRaw Whites Beyazlar Color Picker (Layer Style Color) Renk Seçici (Katman Stili Rengi) Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) + Bundle Arrow-Key Nudges Ok tuşlarıyla kaydırmaları birleştir + Nudge Bundle Pause (ms) Kaydırma birleştirme duraklaması (ms) Appearance Mode Görünüm Modu Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu @@ -3823,6 +3830,8 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + Response curve Yanıt eğrisi + Show home recent files Ana ekranda son dosyaları göster Clear Brush Controls Fırça Denetimlerini Temizle Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala @@ -3876,3 +3885,23 @@ cameraData Lens Lens Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: Redrawing Yeniden çiziliyor +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Gömülü profili kullanın ({profile}) + Convert to the working space ({profile}) Çalışma alanına dönüştürün ({profile}) + Remember this choice for future images Bu seçimi sonraki görüntüler için hatırla + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Soruyu Düzen › Renk Ayarları'ndaki Ask On Mismatch ile yeniden açabilirsiniz. + Button Mode Düğme modu + Button Mode On Düğme modu açık + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Arayüzü koyulaştır + Lighten Interface Arayüzü açıklaştır +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Sert Keski + Chisel Soft Yumuşak Keski + Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç + +# View › Guides › right-click a guide + Edit Selected Guides Seçili Kılavuzları Düzenle + Clear Selected Guides Seçili Kılavuzları Temizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 79ebc8df36..39597c15cb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -324,6 +324,9 @@ # Field Blur pins (#3373) No pin selected Шпильку не вибрано Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетягуйте маркери на контурі, щоб змінити розмір і повернути його, квадрат — щоб заокруглити кути, а точки всередині — щоб задати, де починається розмиття. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Перетягніть шпильку, щоб зсунути смугу, або її кільце, щоб змінити розмиття. Суцільні лінії розширюють різку смугу, пунктирні задають перехід, а точки повертають її. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Перетягуйте маркери на контурі, щоб змінити розмір і повернути його; кільце задає кут розмиття. Iris Blur… Розмиття діафрагми… Tilt-Shift… Нахил і зсув… Path Blur… Розмиття вздовж контуру… @@ -887,9 +890,7 @@ Newspaper Газета Working Spaces Робочі простори Color Management Policies Політики керування кольором - Convert document's colors to the working space Перетворити кольори документа в робочий простір Assign working space Призначити робочий простір - Use the embedded profile (instead of the working space) Використати вбудований профіль (замість робочого простору) Leave as is (don't color manage) Залишити як є (без керування кольором) Discard the embedded profile (don't color manage) Відкинути вбудований профіль (без керування кольором) Embed Color Profile Вбудувати колірний профіль @@ -1677,6 +1678,7 @@ These settings aren't available in PhotoCraft yet. Ці параметри ще недоступні у PhotoCraft. Shortcut removed. Клавіатурне скорочення вилучено. All shortcuts reset to PhotoCraft defaults. Усі клавіатурні скорочення скинуто до типових значень PhotoCraft. + Add Shortcut Додати клавіатурне скорочення Delete Shortcut Видалити клавіатурне скорочення Press keys… Натисніть клавіші… shortcut клавіатурне скорочення @@ -2279,6 +2281,8 @@ Camera Raw Defaults Типові параметри Camera Raw Canvas border Межа полотна Show canvas dots Показувати крапки полотна + Clip thumbnails to layer bounds Обрізати мініатюри до меж шару + Clip Thumbnails to Layer Bounds Обрізати мініатюри до меж шару Canvas color Колір полотна Canvas custom color Довільний колір полотна Classic Класична @@ -2449,7 +2453,6 @@ Cancelled opening {name} Відкриття {name} скасовано The document “{name}” does not have an embedded {mode} profile. Документ «{name}» не має вбудованого профілю {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}. - Embedded: {embedded} Вбудований: {embedded} Working: {working} Робочий простір: {working} Developing {name} Обробка {name} Cancelled {label} Скасовано: {label} @@ -2903,7 +2906,8 @@ fontWeight Italic Курсивний Frame {current} / {total} Кадр {current} / {total} Create new brush from the current settings Створити новий пензель із поточних параметрів Delete brush Видалити пензель - Import Brushes… (.abr) Імпортувати пензлі… (.abr) + Import Brushes… (.abr or image) Імпортувати пензлі… (.abr або зображення) + Import Brush from Image… Імпортувати пензель із зображення… System Info Інформація про систему Click to sample the foreground colour · {key}-click for background Клацання бере зразок переднього плану · {key}+клацання для фону Create Warped Text Створити деформований текст @@ -3605,6 +3609,8 @@ cameraRaw Masking Маскування Color Picker (Layer Style Color) Палітра кольорів (колір стилю шару) Auto Hide Notifications Автоматично приховувати сповіщення Notification Duration (seconds) Тривалість сповіщення (секунди) + Bundle Arrow-Key Nudges Поєднувати зсуви стрілками + Nudge Bundle Pause (ms) Пауза поєднання зсувів (мс) Appearance Mode Режим вигляду Dark Theme Темна тема Light Theme Світла тема @@ -3718,6 +3724,7 @@ cameraData Lens Об'єктив No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана Loading fonts… Завантаження шрифтів… + Response curve Крива відгуку Simulate Pressure Імітувати натиск Stroke Path… Обвести контур… Background Eraser Фонова гумка @@ -3808,6 +3815,7 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Show home recent files Показувати нещодавні файли на головному екрані Clear Brush Controls Очистити параметри пензля Reset All Locked Settings Скинути всі заблоковані параметри Copy Texture to Other Tools Скопіювати текстуру в інші інструменти @@ -3834,6 +3842,9 @@ curves Smooth Згладити Action playback stopped Відтворення операції зупинено Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій + Background (Default) Фон (типовий) +# Batch / Image Processor + Include All Subfolders Включити всі вкладені папки Auto Add/Delete Автододавання/видалення # Native Windows printing qualification @@ -3843,3 +3854,23 @@ curves Smooth Згладити Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Використати вбудований профіль ({profile}) + Convert to the working space ({profile}) Перетворити в робочий простір ({profile}) + Remember this choice for future images Запам’ятати цей вибір для наступних зображень + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Знову ввімкнути запитання можна параметром Ask On Mismatch у Редагування › Налаштування кольору. + Button Mode Режим кнопок + Button Mode On Режим кнопок увімкнено + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Зробити інтерфейс темнішим + Lighten Interface Зробити інтерфейс світлішим +# Bevel & Emboss Technique. + Technique Метод + Chisel Hard Жорстке різьблення + Chisel Soft М'яке різьблення + Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Редагувати вибрані напрямні + Clear Selected Guides Очистити вибрані напрямні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fea5e6b38..ac7123d850 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -511,6 +511,9 @@ # Field Blur pins (#3373) No pin selected Chưa chọn ghim nào Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Kéo các tay nắm trên đường viền để đổi kích thước và xoay, kéo ô vuông để bo tròn góc, và kéo các chấm bên trong để đặt nơi bắt đầu làm mờ. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Kéo ghim để di chuyển dải, hoặc kéo vòng của ghim để đổi độ mờ. Kéo các đường liền để nới rộng dải sắc nét, các đường nét đứt để đặt vùng chuyển tiếp, và các chấm để xoay dải. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Kéo các tay nắm trên đường viền để đổi kích thước và xoay; vòng tròn đặt góc làm mờ. Filter Gallery… Thư viện bộ lọc… Find Edges Tìm cạnh Flame… Ngọn lửa… @@ -1567,7 +1570,6 @@ cameraRaw Shadows Vùng tối Cancelled opening {name} Đã hủy mở {name} The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại. - Embedded: {embedded} Nhúng: {embedded} Working: {working} Không gian làm việc: {working} Cancelled {label} Đã hủy {label} Cancel transform (Esc) Hủy biến đổi (Esc) @@ -1580,8 +1582,6 @@ cameraRaw Shadows Vùng tối Embedded Profile Mismatch Hồ sơ nhúng không khớp Assign working space Gán không gian làm việc Leave as is (don't color manage) Giữ nguyên (không quản lý màu) - Use the embedded profile (instead of the working space) Dùng hồ sơ nhúng (thay vì không gian làm việc) - Convert document's colors to the working space Chuyển màu tài liệu sang không gian làm việc Discard the embedded profile (don't color manage) Bỏ hồ sơ nhúng (không quản lý màu) Native file drag-and-drop is unavailable Kéo thả tệp gốc không khả dụng Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Kéo thả tệp gốc chưa được hỗ trợ trên Wayland. Dùng Tệp › Mở, hoặc chạy PhotoCraft dưới XWayland với `WAYLAND_DISPLAY= photocraft`. @@ -2664,6 +2664,8 @@ shape Appearance Diện mạo Brush preview color Màu xem trước cọ Cache levels Mức bộ nhớ đệm Show canvas dots Hiện chấm vùng vẽ + Clip thumbnails to layer bounds Cắt hình thu nhỏ theo biên của lớp + Clip Thumbnails to Layer Bounds Cắt hình thu nhỏ theo biên của lớp Color picker Bộ chọn màu Color space Không gian màu Column width Độ rộng cột @@ -2804,6 +2806,8 @@ fontWeight Italic Nghiêng Never Show Overlay Không bao giờ hiện lớp phủ Auto Hide Notifications Tự động ẩn thông báo Notification Duration (seconds) Thời lượng thông báo (giây) + Bundle Arrow-Key Nudges Gộp dịch chuyển bằng phím mũi tên + Nudge Bundle Pause (ms) Thời gian chờ gộp dịch chuyển (ms) Dark Theme Giao diện tối Light Theme Giao diện sáng Pro Medium Pro trung bình @@ -3587,11 +3591,14 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + Add Shortcut Thêm phím tắt + Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Show home recent files Hiện tệp gần đây trên màn hình chính Clear Brush Controls Xóa điều khiển cọ Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác @@ -3618,7 +3625,10 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Background (Default) Nền (mặc định) Presentation Trình bày +# Batch / Image Processor + Include All Subfolders Bao gồm tất cả thư mục con Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). @@ -3631,3 +3641,23 @@ curves Smooth Làm mượt Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Dùng hồ sơ nhúng ({profile}) + Convert to the working space ({profile}) Chuyển sang không gian làm việc ({profile}) + Remember this choice for future images Ghi nhớ lựa chọn này cho các ảnh sau + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Bật lại câu hỏi bằng Ask On Mismatch trong Chỉnh sửa › Cài đặt màu. + Button Mode Chế độ nút + Button Mode On Chế độ nút bật + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Làm tối giao diện + Lighten Interface Làm sáng giao diện +# Bevel & Emboss Technique. + Technique Kỹ thuật + Chisel Hard Đục cứng + Chisel Soft Đục mềm + Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Sửa đường căn đã chọn + Clear Selected Guides Xóa đường căn đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 375b61c40b..2307739ed3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -561,6 +561,10 @@ # Field Blur pins (#3373) No pin selected 未选择图钉 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖动轮廓上的手柄可调整大小并旋转,拖动方块可将角变圆,拖动内部的点可设置模糊开始的位置。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 拖动图钉可移动条带,拖动其圆环可更改模糊程度。拖动实线可加宽清晰条带,拖动虚线可设置过渡,拖动圆点可旋转。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 拖动轮廓上的手柄可调整大小并旋转;圆环设置模糊角度。 + Blur Angle 模糊角度 Iris Blur… 光圈模糊… Tilt-Shift… 移轴模糊… Path Blur… 路径模糊… @@ -988,7 +992,6 @@ Contiguous 连续 Contrast 对比度 Control: 控制: - Convert document's colors to the working space 将文档颜色转换到工作空间 Convert to sRGB 转换为 sRGB Copies 份数 Copper 铜色 @@ -1044,6 +1047,7 @@ Delete Group 删除组 Delete Layer Mask 删除图层蒙版 Delete Layers 删除图层 + Add Shortcut 添加快捷键 Delete Shortcut 删除快捷键 Delete Timeline 删除时间轴 Delete Tool Preset 删除工具预设 @@ -1579,7 +1583,6 @@ Use Default 使用默认值 Use Legacy 使用旧版 Use Measurement Scale 使用测量比例 - Use the embedded profile (instead of the working space) 使用嵌入的配置文件(替代工作空间) Uses the foreground and background colors. 使用前景色和背景色。 Variables bind a layer's visibility, text or pixels to a named data slot. 变量将图层的可见性、文字或像素绑定到具名数据项。 Vertical scale 垂直缩放 @@ -1906,6 +1909,8 @@ Camera Raw Defaults Camera Raw 默认设置 Canvas border 画布边框 Show canvas dots 显示画布圆点 + Clip thumbnails to layer bounds 将缩略图裁剪到图层边界 + Clip Thumbnails to Layer Bounds 将缩略图裁剪到图层边界 Canvas color 画布颜色 Canvas custom color 画布自定颜色 Color picker 拾色器 @@ -2094,7 +2099,8 @@ Expand group (⌥-click: all groups) 展开组 (⌥-单击:所有组) Fade steps 渐隐步数 Hide the layer's effects (⌥-click: all layers) 隐藏图层效果 (⌥-单击:所有图层) - Import Brushes… (.abr) 导入画笔… (.abr) + Import Brushes… (.abr or image) 导入画笔… (.abr 或图像) + Import Brush from Image… 从图像导入画笔… Move Group Down 向下移动组 Move Group Up 向上移动组 Reset the brush to the defaults 将画笔重置为默认值 @@ -2261,7 +2267,6 @@ cameraRaw Blending 混合 Cancelled opening {name} 已取消打开 {name} The document “{name}” does not have an embedded {mode} profile. 文档“{name}”没有嵌入的 {mode} 配置文件。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。 - Embedded: {embedded} 嵌入:{embedded} Working: {working} 工作空间:{working} Developing {name} 正在处理 {name} Cancelled {label} 已取消 {label} @@ -2515,6 +2520,8 @@ fontWeight Italic 斜体 Color Picker (Layer Style Color) 拾色器(图层样式颜色) Auto Hide Notifications 自动隐藏通知 Notification Duration (seconds) 通知持续时间(秒) + Bundle Arrow-Key Nudges 合并方向键微移 + Nudge Bundle Pause (ms) 微移合并间隔(毫秒) Appearance Mode 外观模式 Dark Theme 深色主题 Light Theme 浅色主题 @@ -2642,6 +2649,7 @@ cameraData Lens 镜头 Search settings… 搜索设置… No matching settings 没有匹配的设置 Loading fonts… 正在加载字体… + Response curve 响应曲线 Tool 工具 Simulate Pressure 模拟压力 Stroke Path… 描边路径… @@ -2719,6 +2727,7 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Show home recent files 在主屏幕上显示最近的文件 Clear Brush Controls 清除画笔控件 Reset All Locked Settings 复位所有锁定的设置 Copy Texture to Other Tools 将纹理复制到其他工具 @@ -2745,6 +2754,10 @@ curves Smooth 平滑 Action playback stopped 动作播放已停止 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 + Background (Default) 背景(默认) + Content-Aware Fill 内容识别填充 +# Batch / Image Processor + Include All Subfolders 包含所有子文件夹 Auto Add/Delete 自动添加/删除 # Native Windows printing qualification @@ -2754,3 +2767,26 @@ curves Smooth 平滑 Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 使用嵌入的配置文件({profile}) + Convert to the working space ({profile}) 转换到工作空间({profile}) + Remember this choice for future images 以后打开的图像都使用此选择 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 编辑 › 颜色设置 中打开 Ask On Mismatch,重新显示此提示。 + Button Mode 按钮模式 + Button Mode On 按钮模式 开 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 界面变暗 + Lighten Interface 界面变亮 +# Bevel & Emboss Technique. + Technique 方法 + Chisel Hard 雕刻清晰 + Chisel Soft 雕刻柔和 + Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开 +# Export As metadata and profile options + Color Profile 颜色配置文件 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 编辑所选参考线 + Clear Selected Guides 清除所选参考线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index de931038db..478489f8c7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -566,6 +566,10 @@ # Field Blur pins (#3373) No pin selected 未選取圖釘 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖移輪廓上的控點可調整大小並旋轉,拖移方塊可將角變圓,拖移內部的點可設定模糊開始的位置。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 拖移圖釘可移動條帶,拖移其圓環可變更模糊程度。拖移實線可加寬清晰條帶,拖移虛線可設定過渡,拖移圓點可旋轉。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 拖移輪廓上的控點可調整大小並旋轉;圓環設定模糊角度。 + Blur Angle 模糊角度 Iris Blur… 光圈模糊… Tilt-Shift… 傾斜偏移… Path Blur… 路徑模糊… @@ -992,7 +996,6 @@ Contiguous 連續的 Contrast 對比 Control: 控制: - Convert document's colors to the working space 將文件顏色轉換為工作空間 Convert to sRGB 轉換為 sRGB Copies 份數 Copper 銅色 @@ -1047,6 +1050,7 @@ Delete Group 刪除群組 Delete Layer Mask 刪除圖層遮色片 Delete Layers 刪除圖層 + Add Shortcut 新增快速鍵 Delete Shortcut 刪除快速鍵 Delete Timeline 刪除時間軸 Delete Tool Preset 刪除工具預設集 @@ -1575,7 +1579,6 @@ Use Default 使用預設值 Use Legacy 使用舊版 Use Measurement Scale 使用測量比例 - Use the embedded profile (instead of the working space) 使用內嵌描述檔 (而非工作空間) Variables bind a layer's visibility, text or pixels to a named data slot. 變數可將圖層的可見度、文字或像素繫結至具名的資料欄位。 Vertical scale 垂直縮放 Vibrance 自然飽和度 @@ -1899,6 +1902,8 @@ Camera Raw Defaults Camera Raw 預設值 Canvas border 畫布邊框 Show canvas dots 顯示畫布圓點 + Clip thumbnails to layer bounds 將縮圖裁切至圖層邊界 + Clip Thumbnails to Layer Bounds 將縮圖裁切至圖層邊界 Canvas color 畫布顏色 Canvas custom color 畫布自訂顏色 Color picker 檢色器 @@ -2087,7 +2092,8 @@ Expand group ({key}-click: all groups) 展開群組 ({key}+按一下:全部群組) Fade steps 淡出步數 Hide the layer's effects ({key}-click: all layers) 隱藏圖層效果 ({key}+按一下:所有圖層) - Import Brushes… (.abr) 匯入筆刷… (.abr) + Import Brushes… (.abr or image) 匯入筆刷… (.abr 或影像) + Import Brush from Image… 從影像匯入筆刷… Move Group Down 下移群組 Move Group Up 上移群組 Reset the brush to the defaults 將筆刷重設為預設值 @@ -2200,7 +2206,6 @@ Cancelled opening {name} 已取消開啟 {name} The document “{name}” does not have an embedded {mode} profile. 文件「{name}」沒有嵌入的 {mode} 描述檔。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。 - Embedded: {embedded} 嵌入:{embedded} Working: {working} 工作空間:{working} Developing {name} 正在處理 {name} Cancelled {label} 已取消 {label} @@ -2485,6 +2490,8 @@ fontWeight Italic 斜體 Color Picker (Layer Style Color) 檢色器(圖層樣式顏色) Auto Hide Notifications 自動隱藏通知 Notification Duration (seconds) 通知持續時間(秒) + Bundle Arrow-Key Nudges 合併方向鍵微移 + Nudge Bundle Pause (ms) 微移合併間隔(毫秒) Appearance Mode 外觀模式 Dark Theme 深色主題 Light Theme 淺色主題 @@ -2612,6 +2619,7 @@ cameraData Lens 鏡頭 Search settings… 搜尋設定… No matching settings 沒有符合的設定 Loading fonts… 正在載入字體… + Response curve 回應曲線 Tool 工具 Simulate Pressure 模擬壓力 Stroke Path… 筆畫路徑… @@ -2704,6 +2712,7 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Show home recent files 在主畫面上顯示最近的檔案 Clear Brush Controls 清除筆刷控制項 Reset All Locked Settings 重設所有鎖定的設定 Copy Texture to Other Tools 複製紋理至其他工具 @@ -2730,6 +2739,10 @@ curves Smooth 平滑 Action playback stopped 動作播放已停止 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 + Background (Default) 背景(預設) + Content-Aware Fill 內容感知填滿 +# Batch / Image Processor + Include All Subfolders 包含所有子資料夾 Auto Add/Delete 自動增加/刪除 # Native Windows printing qualification @@ -2739,3 +2752,26 @@ curves Smooth 平滑 Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 使用內嵌描述檔 ({profile}) + Convert to the working space ({profile}) 轉換為工作空間 ({profile}) + Remember this choice for future images 之後開啟的影像都使用此選擇 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 編輯 › 色彩設定 中開啟 Ask On Mismatch,重新顯示此提示。 + Button Mode 按鈕模式 + Button Mode On 按鈕模式 開 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 介面變暗 + Lighten Interface 介面變亮 +# Bevel & Emboss Technique. + Technique 方法 + Chisel Hard 雕刻清晰 + Chisel Soft 雕刻柔和 + Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟 +# Export As metadata and profile options + Color Profile 色彩描述檔 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 編輯所選參考線 + Clear Selected Guides 清除所選參考線 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 4839fdfc76..9301f709ce 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -101,6 +101,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("pause", include_bytes!("../../../assets/icons/pause.svg")), ("plus", include_bytes!("../../../assets/icons/plus.svg")), ("pointer", include_bytes!("../../../assets/icons/pointer.svg")), + ("quick-selection", include_bytes!("../../../assets/icons/quick-selection.svg")), ("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")), ("rectangle-vertical", include_bytes!("../../../assets/icons/rectangle-vertical.svg")), ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 215e5994e8..131886ae6b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -133,7 +133,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Dodge => "lollipop", Tool::Burn => "flame", Tool::Sponge => "cloud", - Tool::QuickSelection => "circle-dashed", + Tool::QuickSelection => "quick-selection", Tool::ObjectSelection => "square-dashed-mouse-pointer", Tool::Pen => "pen-tool", Tool::FreeformPen => "pen-freeform", @@ -248,6 +248,36 @@ mod tests { ); } + /// Rasterize an icon to its ink mask at `size` px. + fn ink(name: &str, size: u32) -> Vec { + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { panic!("icon must be embedded bytes") }; + let image = egui_extras::image::load_svg_bytes_with_size( + bytes.as_ref(), + egui::load::SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true }, + &Default::default(), + ) + .unwrap(); + assert_eq!(image.size, [size as usize; 2]); + image.pixels.iter().map(|p| p.a() > 16).collect() + } + + #[test] + fn quick_selection_icon_differs_from_ellipse_marquee() { + // #5116: Quick Selection used to reuse the Ellipse Marquee's dashed circle. + assert_ne!(tool_icon(Tool::QuickSelection), tool_icon(Tool::EllipseMarquee)); + for size in [14_u32, 28] { + let (quick, ellipse) = (ink(tool_icon(Tool::QuickSelection), size), ink(tool_icon(Tool::EllipseMarquee), size)); + let shared = quick.iter().zip(&ellipse).filter(|(a, b)| **a && **b).count(); + let union = quick.iter().zip(&ellipse).filter(|(a, b)| **a || **b).count(); + assert!(union > 0, "icons must have ink at {size}px"); + let overlap = shared as f32 / union as f32; + assert!(overlap < 0.5, "the two glyphs must read as distinct: {overlap:.2} of their ink overlaps at {size}px"); + // The dashed selection arc must actually rasterize: more ink than the bare brush. + let brush = ink(tool_icon(Tool::Brush), size); + assert!(quick.iter().filter(|a| **a).count() > brush.iter().filter(|a| **a).count(), "the dashed arc is missing at {size}px"); + } + } + #[test] fn shared_catalog_preserves_existing_icons() { // Characterize the pre-extraction loader across every real asset. The UI's tint, diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 41edaa1725..c0344ee8c7 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -52,6 +52,16 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu overrides.insert(cmd.clone(), sc.clone()); } } + // A row's other keys are the command's further keys (#3665): Copy's F3 beside ⌘C. + let mut alternates: BTreeMap> = f.get("alternates").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (cmd, keys) in &plan.alternate_keys { + let main = plan.set.get(cmd).map(String::as_str); + if photocraft_engine::prefs::Preferences::default().alternates(cmd, main) == *keys { + alternates.remove(cmd); + } else { + alternates.insert(cmd.clone(), keys.clone()); + } + } let shown = std::path::Path::new(file_name).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file_name.to_string()); let n = plan.set.len().to_string(); let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]); @@ -62,6 +72,7 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu } f.insert("tab".into(), json!(0)); f.insert("overrides".into(), json!(overrides)); + f.insert("alternates".into(), json!(alternates)); f.insert("selected".into(), json!("")); f.insert("capture".into(), json!(false)); f.insert("message".into(), json!(message)); @@ -74,6 +85,7 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, })) } diff --git a/crates/ui-egui/src/kys_import_tests.rs b/crates/ui-egui/src/kys_import_tests.rs index cf389e7650..08523da79b 100644 --- a/crates/ui-egui/src/kys_import_tests.rs +++ b/crates/ui-egui/src/kys_import_tests.rs @@ -180,6 +180,31 @@ fn an_opened_kys_file_opens_the_dialog_filled() { assert!(bad.contains("not a keyboard shortcut set (.kys)"), "{bad}"); } +/// #3665: a row listing Copy's ⌘C and F3 keeps both; the old import kept the first key only, +/// and a row of F3 alone left ⌘C unbound only because the set says so. +#[test] +fn an_imported_row_keeps_its_second_key() { + let mut app = app(); + let set = "\ + F3Cmd+C\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = super::import_text(&mut app, "ps.kys", set).unwrap(); + assert_eq!(r["alternateKeys"]["edit.copy"], json!(["F3"])); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Copy keeps ⌘C: {}", f["overrides"]); + assert!(f["alternates"].get("edit.copy").is_none(), "F3 is Copy's default alternate"); + assert_eq!(f["alternates"]["edit.cut"], json!([]), "the set gives Cut no F2"); + assert_eq!(f["alternates"]["edit.undo"], json!(["F1"])); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(&app, "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.undo"), ["F1"]); +} + type Store = std::sync::Arc>>; /// An app beside a Photoshop whose live set is `set`; `saved_prefs` is this app's own saved diff --git a/crates/ui-egui/src/layer_filter_ui.rs b/crates/ui-egui/src/layer_filter_ui.rs new file mode 100644 index 0000000000..e933d0c165 --- /dev/null +++ b/crates/ui-egui/src/layer_filter_ui.rs @@ -0,0 +1,162 @@ +//! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the +//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode +//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel +//! can read and set them. + +use photocraft_color::BlendMode; +use photocraft_doc::{Layer, LayerContent}; +use serde::{Deserialize, Serialize}; + +use crate::state::UiState; +use crate::{PhotocraftApp, icons, widgets}; + +/// What the Layers panel filters by. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FilterBy { + #[default] + Kind, + Name, + Mode, +} + +/// The filter criteria, copied out of `UiState` for one frame of the Layers panel. +#[derive(Clone, Debug, PartialEq)] +pub struct Filter { + pub by: FilterBy, + pub kinds: Vec, + /// As typed; matched trimmed and ignoring case. + pub name: String, + pub mode: BlendMode, +} + +impl Filter { + pub fn of(ui: &UiState) -> Self { + Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + } + + /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); + /// Kind and Name only once a kind is toggled or some text is typed. + pub fn active(&self) -> bool { + match self.by { + FilterBy::Kind => !self.kinds.is_empty(), + FilterBy::Name => !self.name.trim().is_empty(), + FilterBy::Mode => true, + } + } + + /// Whether `l` is listed under this filter. + pub fn shows(&self, l: &Layer) -> bool { + if !self.active() { + return true; + } + match self.by { + FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), + FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), + FilterBy::Mode => l.blend == self.mode, + } + } +} + +/// The kinds the Kind filter toggles. +pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; + +/// `ui.set {layerFilter: {by?, kinds?, name?, mode?}}`: `current` with the given fields replaced, +/// or an error naming the bad field (nothing changes then). `by` is `kind`, `name` or `mode`; +/// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. +pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { + let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + } + let mut f = + Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + if let Some(by) = o.get("by") { + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + } + if let Some(kinds) = o.get("kinds") { + let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; + f.kinds = Vec::new(); + for k in list { + let Some(k) = k.as_str().filter(|k| KINDS.contains(k)) else { + return Err(format!("layerFilter.kinds: unknown kind {k} (kinds: {})", KINDS.join(", "))); + }; + if !f.kinds.iter().any(|have| have == k) { + f.kinds.push(k.to_string()); + } + } + } + if let Some(name) = o.get("name") { + f.name = name.as_str().ok_or("layerFilter.name must be a string")?.to_string(); + } + if let Some(mode) = o.get("mode") { + let name = mode.as_str().ok_or("layerFilter.mode must be a blend mode name")?; + f.mode = photocraft_engine::commands::blend_from_str(name) + .filter(|m| *m == BlendMode::PassThrough || BlendMode::LAYER_MODES.contains(m)) + .ok_or_else(|| format!("layerFilter.mode: unknown blend mode `{name}`"))?; + } + Ok(f) +} + +/// Store `f` as the Layers panel's filter. +pub fn apply(ui: &mut UiState, f: Filter) { + ui.layer_filter_by = f.by; + ui.layer_filter = f.kinds; + ui.layer_filter_name = f.name; + ui.layer_filter_mode = f.mode; +} + +/// The kind filter's name for `l`'s content ("" for groups, which no kind matches). +fn kind(l: &Layer) -> &'static str { + match &l.content { + LayerContent::Raster(_) => "pixel", + LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", + LayerContent::Text(_) => "type", + LayerContent::Shape(_) => "shape", + LayerContent::Smart(_) => "smart", + LayerContent::Group(_) => "", + } +} + +/// Draw the filter row: the type dropdown, then that type's controls. +pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + widgets::dropdown(ui, "layer-filter-by", &mut app.ui.layer_filter_by, &by, 64.0); + ui.add_space(4.0); + match app.ui.layer_filter_by { + FilterBy::Kind => { + for (kind, icon, tip) in [ + ("pixel", "image", tl!("Filter for pixel layers")), + ("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")), + ("type", "type", tl!("Filter for type layers")), + ("shape", "square", tl!("Filter for shape layers")), + ("smart", "app-window", tl!("Filter for smart objects")), + ] { + let on = app.ui.layer_filter.iter().any(|k| k == kind); + if icons::button(ui, icon, 22.0, on, tip).clicked() { + if on { + app.ui.layer_filter.retain(|k| k != kind); + } else { + app.ui.layer_filter.push(kind.to_string()); + } + } + } + } + FilterBy::Name => { + let width = ui.available_width().max(48.0); + widgets::text_field(ui, ui.id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); + } + FilterBy::Mode => { + let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-mode", &mut app.ui.layer_filter_mode, &modes, width); + } + } + }); +} + +#[cfg(test)] +#[path = "layer_filter_ui_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/layer_filter_ui_tests.rs b/crates/ui-egui/src/layer_filter_ui_tests.rs new file mode 100644 index 0000000000..16400ae120 --- /dev/null +++ b/crates/ui-egui/src/layer_filter_ui_tests.rs @@ -0,0 +1,136 @@ +//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode). + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_color::BlendMode; +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +/// Background, "Ink" (Multiply) and "Paper" in a group "Sheets", and "ink notes" (type) on top. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Paper"})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "Sheets"})).unwrap(); + let paper = layer(&s.active().unwrap().doc, "Paper"); + s.execute("layer.select", json!({"layer": paper})).unwrap(); + let ink = s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": ink, "blend": "Multiply"})).unwrap(); + let sheets = layer(&s.active().unwrap().doc, "Sheets"); + s.execute("layer.select", json!({"layer": sheets})).unwrap(); + let text = s.execute("type.create", json!({"text": "ink notes", "x": 4, "y": 20, "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": text, "name": "ink notes"})).unwrap(); + s +} + +fn layer(doc: &Document, name: &str) -> u64 { + doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}")) +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn filter(by: FilterBy, kinds: &[&str], name: &str, mode: BlendMode) -> Filter { + Filter::of(&UiState { + layer_filter_by: by, + layer_filter: kinds.iter().map(|k| k.to_string()).collect(), + layer_filter_name: name.into(), + layer_filter_mode: mode, + ..UiState::default() + }) +} + +#[test] +fn each_filter_type_lists_its_matches() { + let s = session(); + let doc = &s.active().unwrap().doc; + let all = listed(doc, &Filter::of(&UiState::default())); + assert_eq!(all, ["ink notes", "Sheets", "Ink", "Paper", "Background"]); + // Kind: groups match no kind, as before. + assert_eq!(listed(doc, &filter(FilterBy::Kind, &["pixel"], "", BlendMode::Normal)), ["Ink", "Paper", "Background"]); + assert_eq!(listed(doc, &filter(FilterBy::Kind, &["type"], "", BlendMode::Normal)), ["ink notes"]); + // Name: part of the name, ignoring case and surrounding spaces; groups match too. + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], " INK ", BlendMode::Normal)), ["ink notes", "Ink"]); + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], "sheet", BlendMode::Normal)), ["Sheets"]); + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], "nothing like it", BlendMode::Normal)), Vec::::new()); + // Mode: one blend mode; a group's own Pass Through counts. + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::Multiply)), ["Ink"]); + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::PassThrough)), ["Sheets"]); + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::Normal)), ["ink notes", "Paper", "Background"]); +} + +#[test] +fn only_a_criterion_turns_kind_and_name_on_while_mode_always_filters() { + let kinds_left_on = filter(FilterBy::Name, &["pixel"], "", BlendMode::Multiply); + assert!(!kinds_left_on.active(), "the other types' criteria don't apply"); + assert!(!filter(FilterBy::Name, &[], " ", BlendMode::Normal).active(), "spaces are no criterion"); + assert!(!filter(FilterBy::Kind, &[], "ink", BlendMode::Normal).active()); + assert!(filter(FilterBy::Mode, &[], "", BlendMode::Normal).active(), "Mode filters by Normal at first, like Photoshop"); +} + +#[test] +fn saved_settings_without_the_new_fields_still_load() { + let mut saved = serde_json::to_value(UiState::default()).unwrap(); + let fields = saved.as_object_mut().unwrap(); + for key in ["layer_filter_by", "layer_filter_name", "layer_filter_mode"] { + assert!(fields.remove(key).is_some(), "{key} is saved"); + } + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!((ui.layer_filter_by, ui.layer_filter_name.as_str(), ui.layer_filter_mode), (FilterBy::Kind, "", BlendMode::Normal)); + let round = serde_json::to_value(UiState { layer_filter_by: FilterBy::Mode, ..UiState::default() }).unwrap(); + assert_eq!(round["layer_filter_by"], "mode"); +} + +fn harness() -> Harness<'static, PhotocraftApp> { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h +} + +/// The names of the rows the Layers panel drew, top to bottom. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> Vec { + let doc = &h.state().session.active().unwrap().doc; + let mut rows = crate::layer_row_ui::recorded(&h.ctx); + rows.sort_by(|a, b| a.row.top().total_cmp(&b.row.top())); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_dropdown_switches_the_panel_between_filter_types() { + let mut h = harness(); + assert_eq!(drawn(&h), ["ink notes", "Sheets", "Ink", "Paper", "Background"]); + // The type is a real dropdown now: open it and pick Name. + // A dropdown shows its choice as the combo box's value. + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Name").last().expect("Name in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert_eq!(drawn(&h), ["ink notes", "Sheets", "Ink", "Paper", "Background"], "no text yet: every layer"); + h.state_mut().ui.layer_filter_name = "ink".into(); + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes", "Ink"]); + // Mode starts at Normal; the kind toggles left on under Kind don't leak into it. + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.state_mut().ui.layer_filter_by = FilterBy::Mode; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes", "Paper", "Background"]); + h.state_mut().ui.layer_filter_mode = BlendMode::Multiply; + h.run_steps(4); + assert_eq!(drawn(&h), ["Ink"]); + // Back to Kind: its own criterion applies again. + h.state_mut().ui.layer_filter_by = FilterBy::Kind; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes"]); +} diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 7ac725a7cb..5a6e60f792 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -203,6 +203,16 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } ui.close(); } + ui.separator(); + // Photoshop's Clip Thumbnails to Layer Bounds: frame each layer's pixels, not the canvas (#4281). + let mut clip = app.session.prefs().interface.clip_thumbnails_to_layer_bounds; + if crate::widgets::checkbox(ui, &mut clip, tl!("Clip Thumbnails to Layer Bounds")).changed() { + if let Err(e) = app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": clip})) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } } fn rects_id() -> egui::Id { diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index af3e36c904..3a1807cf18 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -86,6 +86,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { const POS: &[(&str, &str)] = &[("outside", "Outside"), ("inside", "Inside"), ("center", "Center")]; const GSTYLE: &[(&str, &str)] = &[("linear", "Linear"), ("radial", "Radial"), ("angle", "Angle"), ("reflected", "Reflected"), ("diamond", "Diamond")]; const BSTYLE: &[(&str, &str)] = &[("inner", "Inner Bevel"), ("outer", "Outer Bevel"), ("emboss", "Emboss"), ("pillow", "Pillow Emboss")]; + const TECHNIQUE: &[(&str, &str)] = &[("smooth", "Smooth"), ("chiselHard", "Chisel Hard"), ("chiselSoft", "Chisel Soft")]; const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; const CONTOUR: P = P::Contour(Some("antiAlias")); @@ -163,6 +164,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ], "bevelEmboss" => &[ ("style", "Style", P::Choice(BSTYLE)), + ("technique", "Technique", P::Choice(TECHNIQUE)), ("depth", "Depth", P::Slider(1.0, 1000.0, "%")), ("direction", "Direction", P::Choice(DIR)), ("size", "Size", P::Slider(0.0, 250.0, "px")), @@ -413,14 +415,15 @@ fn set_param(f: &mut Map, id: &str, key: &str, value: Value) { /// A fresh instance id: one past the highest numbered `fxN` in the list. fn next_id(f: &Map) -> String { - let max = effects_of(f) + let used: std::collections::HashSet = effects_of(f) .iter() .filter_map(|e| e.get("id").and_then(Value::as_str)) .filter_map(|id| id.strip_prefix("fx")) .filter_map(|n| n.parse::().ok()) - .max() - .unwrap_or(0); - format!("fx{}", max + 1) + .collect(); + let max = used.iter().copied().max().unwrap_or(0); + let next = max.checked_add(1).or_else(|| (1_u64..).find(|n| !used.contains(n))).unwrap_or(1); + format!("fx{next}") } /// Adds an instance of `kind`, right after the last one of its kind (the list, @@ -1436,6 +1439,25 @@ mod tests { } } + #[test] + fn adding_an_effect_after_maximum_instance_id_uses_an_unused_id() { + // Imported and scripted effects can provide arbitrary instance IDs. + let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); + let mut fields = initial_fields(&layer, None, 120.0); + fields.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "stroke", "on": true, "params": {}}, + {"id": format!("fx{}", u64::MAX), "kind": "stroke", "on": false, "params": {}} + ]), + ); + add_instance(&mut fields, "stroke"); + + let ids: Vec<&str> = effects_of(&fields).iter().filter_map(|e| e.get("id").and_then(Value::as_str)).collect(); + assert_eq!(ids, ["fx1", "fx18446744073709551615", "fx2"]); + assert_eq!(fields["selected"], json!("fx2")); + } + #[test] fn initial_fields_lists_every_instance_and_selects() { let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); @@ -1857,6 +1879,26 @@ mod tests { assert_eq!(d.doc.layer(id).unwrap().effects.items.len(), 2); } + #[test] + fn bevel_technique_is_selectable_and_round_trips() { + let Some((_, _, P::Choice(options))) = spec("bevelEmboss").iter().find(|(k, _, _)| *k == "technique") else { + panic!("Bevel & Emboss has no Technique dropdown"); + }; + let values: Vec<&str> = options.iter().map(|(v, _)| *v).collect(); + assert_eq!(values, ["smooth", "chiselHard", "chiselSoft"]); + + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"technique": "chiselSoft"})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + assert_eq!(entry(&f, "fx1").unwrap()["params"]["technique"], json!("chiselSoft")); + set_param(&mut f, "fx1", "technique", json!("chiselHard")); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::BevelEmboss(b) = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!("expected bevel") }; + assert_eq!(b.technique, photocraft_doc::effects::BevelTechnique::ChiselHard); + } + #[test] fn unchanged_global_light_angle_is_not_applied() { let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); @@ -1990,6 +2032,56 @@ mod tests { assert_eq!(color(&d.doc), photocraft_color::Color::rgb(0.0, 1.0, 0.0), "OK commits with Preview off"); } + /// #3648's steps in one dialog session: disable the stroke row, add a Color Overlay, + /// untick Preview. Preview off shows the document as saved — the stroke was never + /// disabled in it, so it keeps rendering until OK, exactly like Photoshop's preview + /// semantics; nothing disabled resurrects, and OK applies the dialog's state. + #[test] + fn issue_steps_disable_stroke_add_overlay_untick_preview() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + // Steps 1-3: a stroke is enabled and OK'd; reopening the dialog loads it enabled. + s.execute("layer.layerStyle.stroke", json!({"size": 40, "position": "inside", "color": "#00ff00"})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let rows = f["effects"].as_array().unwrap(); + assert_eq!(rows.len(), 1, "the saved stroke is the only row"); + assert_eq!((rows[0]["kind"].as_str(), rows[0]["on"].as_bool()), (Some("stroke"), Some(true))); + // Step 4: disable the stroke row — a pending edit, the document is untouched. + f["effects"][0]["on"] = json!(false); + // Step 5: add a Color Overlay (a new instance: no fx snapshot, factory defaults). + f["effects"].as_array_mut().unwrap().push(json!({ + "id": "fx2", + "kind": "colorOverlay", + "on": true, + "params": {"color": "#0000ff"}, + })); + // While Preview is on, the live preview honours the in-dialog disable: the stroke + // is disabled in the scratch document, the overlay renders. + let shown = preview_document(&st.doc, &s.patterns, &f).unwrap(); + let mut shown_effects = shown.layer(id).unwrap().effects.items.iter(); + assert!(!shown_effects.find(|e| kind_of(e) == "stroke").is_none_or(|e| e.enabled()), "the preview leaves the stroke off"); + assert!(shown_effects.find(|e| kind_of(e) == "colorOverlay").is_some_and(|e| e.enabled()), "the preview shows the overlay"); + // Step 6: untick Preview — the canvas shows the real document, whose stroke is + // still enabled (the disable is an unapplied edit). This is the state the issue + // reports; it is the saved state, not a resurrected effect. + f.insert("preview".into(), json!(false)); + let green: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 32, "y": 32})).unwrap()).unwrap(); + assert!(green[1] > 0.9 && green[0] < 0.1, "preview off shows the saved stroke: {green:?}"); + // OK (with Preview off) applies the dialog state: the stroke is now disabled in the + // document too, the overlay shows, and the dialog — which never models the list's + // master switch — leaves it at its default. + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let d = s.active().unwrap(); + let effects = &d.doc.layer(id).unwrap().effects; + assert!(!effects.items.iter().find(|e| kind_of(e) == "stroke").is_none_or(|e| e.enabled()), "OK disables the stroke"); + assert!(effects.items.iter().find(|e| kind_of(e) == "colorOverlay").is_some_and(|e| e.enabled()), "OK keeps the overlay on"); + assert!(effects.enabled, "the list's master switch keeps its value"); + let blue: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 32, "y": 32})).unwrap()).unwrap(); + assert!(blue[2] > 0.9 && blue[1] < 0.1, "after OK only the overlay renders: {blue:?}"); + } + #[test] fn new_style_saves_pending_state_and_a_swatch_apply_restores_it() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/layer_thumb_bounds_tests.rs b/crates/ui-egui/src/layer_thumb_bounds_tests.rs new file mode 100644 index 0000000000..66ea820cd6 --- /dev/null +++ b/crates/ui-egui/src/layer_thumb_bounds_tests.rs @@ -0,0 +1,75 @@ +//! #4281: Clip Thumbnails to Layer Bounds. A small object in a large document fills its Layers +//! panel thumbnail instead of being a speck in an empty canvas-sized one. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::mask_thumbs_ui::THUMB_LAYER; + +fn harness() -> (Harness<'static, PhotocraftApp>, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 1000})).unwrap(); + let id = s.execute("layer.new.layer", json!({"name": "Speck"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 100, "y": 200, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dockTabs": {"layers": 0}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(8); + (h, id) +} + +fn frame(h: &Harness<'_, PhotocraftApp>, id: u64) -> Rect { + *h.state().thumb_frames.get(&LayerId(id)).expect("layer thumbnail drawn") +} + +fn texture_area(h: &Harness<'_, PhotocraftApp>, id: u64) -> usize { + let (_, tex) = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).expect("layer thumbnail drawn"); + let [w, h] = tex.size(); + w * h +} + +#[test] +fn layer_thumbnails_frame_the_canvas_by_default() { + let (h, id) = harness(); + assert!(!h.state().session.prefs().interface.clip_thumbnails_to_layer_bounds); + assert_eq!(frame(&h, id), Rect::new(0, 0, 2000, 1000)); +} + +#[test] +fn clipped_layer_thumbnails_frame_the_layer_pixels() { + let (mut h, id) = harness(); + let before = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).map(|(r, _)| *r).unwrap(); + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": true})).unwrap(); + h.run_steps(4); + assert_eq!(frame(&h, id), Rect::new(100, 200, 120, 210), "framed on the filled rect"); + let after = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).map(|(r, _)| *r).unwrap(); + assert_ne!(before, after, "toggling the preference rebuilds the thumbnail"); + assert_eq!(texture_area(&h, id), 64 * 64, "still a 64 px square texture"); + + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": false})).unwrap(); + h.run_steps(4); + assert_eq!(frame(&h, id), Rect::new(0, 0, 2000, 1000), "and back to the canvas"); +} + +#[test] +fn empty_and_off_canvas_layers_keep_the_canvas_frame() { + let canvas = Rect::new(0, 0, 50, 40); + let raster = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + let mut empty = photocraft_doc::Layer::new("Empty", photocraft_doc::LayerContent::Raster(raster)); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), canvas); + empty.surface_mut().unwrap().write_pixel(-10, -10, &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), canvas, "pixels only off the canvas"); + empty.surface_mut().unwrap().write_pixel(45, 5, &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), Rect::new(0, 0, 46, 6), "the layer bounds, cut to the canvas"); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, false), canvas); +} diff --git a/crates/ui-egui/src/layer_tree_ui.rs b/crates/ui-egui/src/layer_tree_ui.rs index 2c1040c246..d00dffb8da 100644 --- a/crates/ui-egui/src/layer_tree_ui.rs +++ b/crates/ui-egui/src/layer_tree_ui.rs @@ -39,7 +39,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { /// Mask session's working text, which is no layer to the user: Photoshop shows only the mask /// overlay while you type (#3036). pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> { - let mut rows = display_rows(doc, !app.ui.layer_filter.is_empty()); + let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active()); if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) { rows.retain(|(_, l)| l.id.0 != working); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbdadf89cb..72105c940d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,7 @@ pub mod color_picker_ui; pub mod color_range_ui; pub mod comps_ui; pub mod control; +pub mod corner_radius; pub mod credits; pub mod crop_mode; pub mod crop_overlay; @@ -100,6 +101,7 @@ pub mod gpu_canvas; pub mod gpu_status; pub mod gradient_ui; pub mod guide_layout_ui; +pub mod guide_select; pub mod hold_keys; pub mod i18n; mod icon_data; @@ -108,6 +110,7 @@ pub mod jobs_ui; pub mod kys_import; pub mod lasso_ui; pub(crate) mod last_brush; +pub mod layer_filter_ui; pub mod layer_menu_ui; pub mod layer_pick_ui; pub mod layer_picker; @@ -125,6 +128,7 @@ mod mask_props_ui; pub mod mask_thumbs_ui; pub mod mask_trash_prompt; pub mod menu_catalog; +pub mod menu_keys; pub mod menu_nav; pub mod menus; pub mod monitor_status; @@ -138,6 +142,7 @@ pub mod new_doc_ui; pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; +pub mod nudge_bundle; mod numeric_expression; pub mod object_finder; mod opacity_keys; @@ -270,6 +275,12 @@ pub struct ExportSettings { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility; see [`PhotocraftApp::save_settings`]). pub psd_maximize_compatibility: bool, + /// Embed the document's ICC profile when the format supports it. On for Save As and, unless + /// Export As turns it off, for export. + pub embed_icc: bool, + /// Embed EXIF, resolution and other non-XMP metadata when the format supports it. Export As + /// turns this off for Metadata: None; Save As leaves it on. + pub embed_metadata: bool, } impl Default for ExportSettings { @@ -283,10 +294,35 @@ impl Default for ExportSettings { tga_bits: None, image_previews: true, psd_maximize_compatibility: true, + embed_icc: true, + embed_metadata: true, } } } +impl ExportSettings { + /// The IO export options these encoder settings select. Save As and Export As share this so + /// a profile or metadata choice cannot be applied in one and forgotten in the other. + pub fn io_options(&self) -> photocraft_io::ExportOptions { + let mut opts = photocraft_io::ExportOptions::default(); + if let Some(q) = self.jpeg_quality { + opts.encode.jpeg_quality = q; + } + opts.encode.webp_lossless = self.webp_lossless; + if let Some(q) = self.webp_quality { + opts.encode.webp_quality = q; + } + opts.encode.embed_icc = self.embed_icc; + opts.encode.embed_metadata = self.embed_metadata; + opts.tiff_layers = self.tiff_layers; + opts.xmp = if self.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; + opts.tga_bits = self.tga_bits; + opts.image_previews = self.image_previews; + opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts + } +} + /// The document's lone layer when it is a plain raster a flat file keeps: visible, at full /// opacity, in Normal mode, with no masks, effects or blending restrictions (#2234). pub(crate) fn plain_raster(doc: &Document) -> Option<&photocraft_doc::Layer> { @@ -512,6 +548,8 @@ pub struct PhotocraftApp { navigator_gpu: HashMap, /// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`). navigator_plain: std::collections::HashSet, + /// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`). + info_samplers: Option, /// Display profile readings (#569). monitors: monitor_status::State, checker: Option, @@ -621,6 +659,9 @@ pub struct PhotocraftApp { pub fps: f32, last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, + /// The document area each layer thumbnail in `thumbs` frames (the canvas, or the layer's + /// bounds with Clip Thumbnails to Layer Bounds; #4281). + thumb_frames: HashMap, /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each /// is made once (`snapshot_thumb`, #2966). snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, @@ -661,6 +702,8 @@ pub struct PhotocraftApp { pub(crate) move_mods: move_mods::MoveDrag, /// Cached document for a modal text Style Options color preview. pub(crate) text_style_preview: Option, + /// The run of arrow-key nudges that share one History state (nudge_bundle). + pub(crate) nudge_bundle: nudge_bundle::NudgeBundle, /// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error). pub(crate) style_preview: Option<(u64, Result, String>)>, pub(crate) solid_fill_preview: Option, @@ -774,6 +817,7 @@ impl PhotocraftApp { display_memo: HashMap::new(), navigator_gpu: HashMap::new(), navigator_plain: std::collections::HashSet::new(), + info_samplers: None, monitors: Default::default(), checker: None, drag: None, @@ -825,6 +869,7 @@ impl PhotocraftApp { fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), + thumb_frames: HashMap::new(), snapshot_thumbs: HashMap::new(), thumb_documents: Vec::new(), bounds_cache: HashMap::new(), @@ -859,6 +904,7 @@ impl PhotocraftApp { clip_read_for_paste: false, transform_preview: None, move_mods: Default::default(), + nudge_bundle: Default::default(), style_preview: None, text_style_preview: None, solid_fill_preview: None, @@ -1316,6 +1362,13 @@ impl PhotocraftApp { /// [`Self::save_as`], writing a Targa with `tga_bits` per pixel when given (no Targa Options /// prompt): `file.save` and `file.saveAs` with `tgaBits`. pub fn save_as_with(&mut self, path: Option, tga_bits: Option) -> Result { + self.save_dialog_flow(path, tga_bits, false) + } + + /// [`Self::save_as_with`]. `plain_save` marks File › Save that had to ask for a file name + /// (a flat file): confirming the document's own file is recorded as a `file.save` step, + /// which writes whichever file the action is played on, not as a Save As to a fixed path. + pub(crate) fn save_dialog_flow(&mut self, path: Option, tga_bits: Option, plain_save: bool) -> Result { if self.save_options_open() { return Err("Answer TIFF or Targa Options before starting another save".into()); } @@ -1355,7 +1408,16 @@ impl PhotocraftApp { } }; let doc = st.doc.id; - self.pick_save(&suggested, move |app, path| app.with_document(doc, |app| app.save_to(path, tga_bits))) + self.pick_save(&suggested, move |app, path| { + app.with_document(doc, |app| { + let own = app.session.active().and_then(|st| st.path.clone()); + let r = app.save_to(path.clone(), tga_bits)?; + if plain_save && own.as_deref() == Some(path.as_str()) { + app.journal_as_plain_save(&path); + } + Ok(r) + }) + }) } /// [`Self::save_as_with`] once the path is known. @@ -1402,6 +1464,17 @@ impl PhotocraftApp { Ok(r) } + /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]). + fn journal_as_plain_save(&mut self, path: &str) { + if let Some(last) = self.session.journal.last_mut() + && last.0 == "file.saveAs" + && last.1.get("path").and_then(Value::as_str) == Some(path) + && last.1.as_object().is_some_and(|o| o.len() == 1) + { + *last = ("file.save".into(), Value::Object(Default::default())); + } + } + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the /// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { @@ -1409,11 +1482,18 @@ impl PhotocraftApp { return Err("Answer TIFF or Targa Options before starting another save".into()); } if id == "file.save" { + // The file the action is played on: one that holds the whole document, or a flat + // file a recorded Save confirmed in the dialog while the document is still a plain + // raster, so it is never flattened over (#3531, #3806). let st = self.session.active().ok_or("no document")?; - let path = - st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p)).ok_or( - "Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file, and to OpenRaster only when it holds everything", - )?; + let flat_own = |p: &str| { + !photocraft_engine::file_cmds::saves_in_place(p) + && plain_raster(&st.doc).is_some() + && photocraft_engine::file_cmds::extension(p).is_some_and(|e| photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)) + }; + let path = st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p) || flat_own(p)).ok_or( + "Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file (OpenRaster only when it holds everything), or to its own flat file while the document is a single plain layer", + )?; return self.save_in_place(path); } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; @@ -1676,6 +1756,12 @@ impl PhotocraftApp { } impl eframe::App for PhotocraftApp { + fn on_exit(&mut self) { + if let Err(err) = tool_memory::save_on_exit(self) { + log::warn!("Preferences: could not save the final tool selection: {err}"); + } + } + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas @@ -2040,6 +2126,7 @@ impl PhotocraftApp { // Native files can restore IDs: retain the union across all open documents, rather // than deleting an ID just because one document containing it was closed. self.thumbs.retain(|key, _| live.contains(key)); + self.thumb_frames.retain(|id, _| live.contains(&(*id, mask_thumbs_ui::THUMB_LAYER))); } self.thumb_documents = documents.iter().map(|st| (st.doc.id, std::sync::Arc::downgrade(&st.doc))).collect(); } @@ -2065,25 +2152,31 @@ impl PhotocraftApp { self.checker = None; } - /// Cached 64px thumbnail of a pixel-ish layer, laid out in document space. - pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> egui::TextureId { + /// Cached 64px thumbnail of a pixel-ish layer, letterboxed in a square, and the document + /// area it frames: the canvas, or with Clip Thumbnails to Layer Bounds the layer's pixels. + pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> (egui::TextureId, photocraft_geom::Rect) { + let clip = self.session.prefs().interface.clip_thumbnails_to_layer_bounds; // Key by content, not document revision: COW tiles change pointer only when their pixels // change, so unrelated edits (e.g. painting another layer) don't rebuild this thumbnail. - let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40; + let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40 ^ u64::from(clip) << 63; let key = (layer.id, mask_thumbs_ui::THUMB_LAYER); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev + && let Some(frame) = self.thumb_frames.get(&layer.id) { - return tex.id(); + return (tex.id(), *frame); } + let canvas = doc.bounds(); + let frame = layer_thumb_frame(layer, canvas, clip); let mut px = [0.0f32; 8]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image(frame, 64, |x, y| { let Some(s) = layer.surface() else { return [0.0; 4] }; let n = s.channels(); s.read_pixel(x, y, &mut px[..n]); photocraft_raster::to_rgba(&s.format(), &px[..n]) }); - self.store_thumb(ctx, key, rev, img) + self.thumb_frames.insert(layer.id, frame); + (self.store_thumb(ctx, key, rev, img), frame) } /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, @@ -2118,7 +2211,7 @@ impl PhotocraftApp { return tex.id(); } let mut v = [0.0f32; 1]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image(doc.bounds(), 64, |x, y| { mask.surface.read_pixel(x, y, &mut v); [v[0], v[0], v[0], 1.0] }); @@ -2157,9 +2250,20 @@ pub fn surface_fingerprint(s: &photocraft_raster::Surface) -> u64 { h } -/// Square thumbnail of the canvas area, letterboxed, sampling `f(x, y)` in document space. -fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { - let (w, h) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); +/// The document area a layer thumbnail frames: the canvas, or with `clip` the layer's pixels on +/// it (the canvas again when it has none there, so an empty layer still shows the page shape). +fn layer_thumb_frame(layer: &photocraft_doc::Layer, canvas: photocraft_geom::Rect, clip: bool) -> photocraft_geom::Rect { + if !clip { + return canvas; + } + let bounds = layer.surface().map_or(photocraft_geom::Rect::EMPTY, photocraft_compose::bounds::content_bounds).intersect(&canvas); + if bounds.is_empty() { canvas } else { bounds } +} + +/// Square thumbnail of the document area `frame`, letterboxed, sampling `f(x, y)` in document +/// space. +fn thumb_image(frame: photocraft_geom::Rect, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { + let (w, h) = (frame.width().max(1) as f32, frame.height().max(1) as f32); let scale = w.max(h) / side as f32; let (ox, oy) = ((side as f32 - w / scale) / 2.0, (side as f32 - h / scale) / 2.0); let mut px = vec![egui::Color32::TRANSPARENT; side * side]; @@ -2170,7 +2274,7 @@ fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; if dx < 0.0 || dy < 0.0 || dx >= w || dy >= h { continue; } - let c = f(dx as i32, dy as i32); + let c = f(frame.x0.saturating_add(dx as i32), frame.y0.saturating_add(dy as i32)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; px[ty * side + tx] = egui::Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); } @@ -2497,6 +2601,9 @@ mod profile_prompt_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod empty_workspace_tests; + #[cfg(test)] mod pen_variants_tests; @@ -2547,6 +2654,8 @@ mod blur_pins_tests; #[cfg(test)] mod move_outline_tests; +#[cfg(test)] +mod layer_thumb_bounds_tests; #[cfg(test)] mod mask_thumb_refresh_tests; @@ -2577,6 +2686,9 @@ mod marquee_tests; #[cfg(test)] mod canvas_menu_tests; +#[cfg(test)] +mod nudge_bundle_tests; + #[cfg(test)] mod caps_lock_tests; @@ -2878,3 +2990,6 @@ mod channel_tint_tests { assert_eq!(channel_tint(Rgb, 0, 77, false), Color32::from_gray(77)); } } + +#[cfg(test)] +mod options_hit_area_tests; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index cf4832904a..40e9bbd392 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -147,6 +147,23 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +/// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis +/// snapped to the pixel grid on its own. The readout and the selection are square, and the +/// Elliptical Marquee's circle too. +#[test] +fn shift_square_pressed_between_pixels_has_equal_sides() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + press_at(&mut h, 50.3, 50.7, Modifiers::NONE); + mods(&mut h, Modifiers::SHIFT); + move_to(&mut h, 150.8, 90.0); + assert!(readout(&h, 102, 102), "{tool:?}: the square covers every pixel it touched, on both axes"); + release_at(&mut h, 150.8, 90.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(50, 50, 152, 152), "{tool:?}"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { @@ -545,12 +562,14 @@ fn hovering_inside_the_selection_shows_the_move_cursor() { } /// #1428: with a marquee, the arrow keys nudge the selection outline 1 px, ⇧ 10 px (Photoshop); -/// each press is one undoable step. +/// each press is one undoable step with Bundle Arrow-Key Nudges off, a run of them one step with +/// it on (#2259, the default). #[test] fn arrow_keys_nudge_the_selection_outline() { use egui::Key; - for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (tool, bundle) in [(Tool::RectMarquee, false), (Tool::EllipseMarquee, false), (Tool::RectMarquee, true), (Tool::EllipseMarquee, true)] { let mut h = harness(tool); + h.state_mut().session.edit_prefs(|p| p.tools.bundle_nudges = bundle); press_at(&mut h, 100.0, 80.0, Modifiers::NONE); release_at(&mut h, 200.0, 160.0, Modifiers::NONE); let drawn = selection(&h); @@ -569,9 +588,15 @@ fn arrow_keys_nudge_the_selection_outline() { h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); h.run_steps(1); assert_eq!(selection(&h), shifted(-9, 9), "{tool:?} shift-left 10 px"); - assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 4, "one step per press"); + let nudges = h.state().session.active().unwrap().history.past_len() - steps; h.state_mut().session.undo(); - assert_eq!(selection(&h), shifted(1, 9), "{tool:?} undo takes back one nudge"); + if bundle { + assert_eq!(nudges, 1, "{tool:?} four quick presses, one step"); + assert_eq!(selection(&h), drawn, "{tool:?} undo takes back the run"); + } else { + assert_eq!(nudges, 4, "{tool:?} one step per press"); + assert_eq!(selection(&h), shifted(1, 9), "{tool:?} undo takes back one nudge"); + } } } diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs new file mode 100644 index 0000000000..5762b2a945 --- /dev/null +++ b/crates/ui-egui/src/menu_keys.rs @@ -0,0 +1,303 @@ +//! The menu bar from the keyboard (#4988), as Photoshop's on Windows: a tap on Alt (or F10) +//! puts the keyboard on the bar (File lit; ← / → move along it, ↓ / ↩ / Space open the lit menu, +//! Esc or a click leaves), Alt+letter opens the menu with that mnemonic straight away, and in an +//! open menu a letter runs the row with that mnemonic (or opens its submenu). +//! +//! Mnemonics are underlined while Alt is held, the bar is focused or a menu was opened from the +//! keyboard; a menu opened with the mouse shows none until Alt is pressed, as Windows does. +//! They are assigned per menu level from the labels as shown (so translated), unique within +//! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V +//! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys +//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic. +//! +//! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native +//! menu bar replaces the in-window one and this never runs. + +use egui::{Color32, Event, Id, Key, Modifiers, Stroke, Ui}; + +use crate::menu_nav::Nav; + +/// Photoshop's mnemonics for the top menus (Windows), used where the shown label has the letter. +pub const TOP_PREFERRED: [(&str, char); 10] = [ + ("File", 'F'), + ("Edit", 'E'), + ("Image", 'I'), + ("Layer", 'L'), + ("Type", 'Y'), + ("Select", 'S'), + ("Filter", 'T'), + ("View", 'V'), + ("Window", 'W'), + ("Help", 'H'), +]; + +/// Keyboard state of the menu bar between frames. +#[derive(Clone, Debug, Default)] +pub struct Keys { + /// The title the keyboard is on with no menu open, in screen order (Alt tap, F10). + pub focus: Option, + /// Underline the mnemonics: the bar is focused or a menu was opened from the keyboard. + pub underline: bool, + /// Alt is down (from its key events, whatever the platform reports as modifiers). + alt_down: bool, + /// Alt went down and nothing else was pressed since: releasing it is a tap. + alt_tap: bool, + /// A menu was just opened from the keyboard: light its first row once it is drawn. + first_row: bool, +} + +fn keys_id() -> Id { + Id::new("pc-menu-bar-keys") +} + +fn focus_id() -> Id { + Id::new("pc-menu-bar-focus") +} + +/// Is the keyboard on the menu bar (no menu open yet)? The shortcuts leave the keys to it then, +/// as they do for an open menu: ← / → / ↓ / ↩ / Esc and the letters drive the bar. +pub fn is_focused(ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(focus_id())).is_some_and(|seen| seen + 2 >= pass) +} + +/// The mnemonic a key press stands for: the letter or digit of a plain key. +fn key_char(key: Key) -> Option { + let name = key.name(); + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => Some(c.to_ascii_uppercase()), + _ => None, + } +} + +/// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it, +/// else the first unused ASCII letter or digit, trying the first character of each word before +/// the others. Compared case-insensitively; the returned letter is upper case. +pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> { + let mut used: Vec = Vec::new(); + let mut out = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + let upper: Vec = label.chars().map(|c| c.to_ascii_uppercase()).collect(); + let has = |c: char| upper.contains(&c.to_ascii_uppercase()); + let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c)); + if pick.is_none() { + let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next()); + let rest = label.chars(); + pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c)); + } + if let Some(c) = pick { + used.push(c); + } + out.push(pick); + } + out +} + +/// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined +/// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's. +pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, color: Option) -> egui::WidgetText { + let color = color.unwrap_or(Color32::PLACEHOLDER); + let font = egui::TextStyle::Button.resolve(ui.style()); + let plain = egui::TextFormat { font_id: font.clone(), color, ..Default::default() }; + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let Some((i, len)) = at else { + return egui::RichText::new(format!("{prefix}{label}")).color(color).into(); + }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + let underlined = egui::TextFormat { underline: Stroke::new(1.0, color), ..plain.clone() }; + let before = format!("{prefix}{}", &label[..i]); + if !before.is_empty() { + job.append(&before, 0.0, plain.clone()); + } + job.append(&label[i..i + len], 0.0, underlined); + if i + len < label.len() { + job.append(&label[i + len..], 0.0, plain); + } + job.into() +} + +impl Keys { + /// Last frame's state. + pub fn load(ctx: &egui::Context) -> Keys { + ctx.data(|d| d.get_temp(keys_id())).unwrap_or_default() + } + + /// The state as the last frame left it (tests, inspection). + pub fn current(ctx: &egui::Context) -> Keys { + Self::load(ctx) + } + + pub fn store(self, ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + if self.focus.is_some() { + d.insert_temp(focus_id(), pass); + } else { + d.remove::(focus_id()); + } + d.insert_temp(keys_id(), self); + }); + } + + /// Underline the mnemonics this frame? + pub fn show_underlines(&self, ctx: &egui::Context) -> bool { + self.underline || self.focus.is_some() || self.alt_down || ctx.input(|i| i.modifiers.alt) + } + + /// A menu just opened from the keyboard: light its first row (once, when it is drawn). + pub fn take_first_row(&mut self, nav: &mut Nav) { + if self.first_row && nav.top.is_some() { + nav.highlight_first(0); + self.first_row = false; + } + } + + /// Handle this frame's keys, after the menus were drawn. `tops` are the titles' popup ids + /// and `mnemonics` their letters, both in screen order; `nav` is the open menu's state. A + /// command a mnemonic ran lands in `clicked`. + pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], mnemonics: &[Option], nav: &mut Nav, clicked: &mut Option) { + if tops.is_empty() { + return; + } + let events = ctx.input(|i| i.events.clone()); + let mut tap = false; + for e in &events { + match e { + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => { + self.alt_down = true; + self.alt_tap = true; + } + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => { + self.alt_down = false; + tap |= std::mem::take(&mut self.alt_tap); + } + Event::Key { pressed: true, .. } | Event::Text(_) | Event::PointerButton { pressed: true, .. } => self.alt_tap = false, + _ => {} + } + } + if events.iter().any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) { + self.focus = None; + self.underline = false; + } + let open = nav.top.is_some(); + let n = tops.len(); + let open_top = |i: usize, keys: &mut Keys| { + if let Some(&id) = tops.get(i) { + egui::Popup::open_id(ctx, id); + keys.focus = None; + keys.underline = true; + keys.first_row = true; + ctx.request_repaint(); + } + }; + let consume = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k)); + // Alt+letter opens that menu, open or not (an open one switches), as on Windows. + let alt_letter = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if modifiers.alt && !modifiers.command && !modifiers.ctrl => key_char(*key).map(|c| (*key, c)), + _ => None, + }); + if let Some((key, c)) = alt_letter + && let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) + { + consume(Modifiers::ALT, key); + self.alt_tap = false; + open_top(i, self); + return; + } + if open { + // A letter runs the row with that mnemonic in the level the arrows are in. + let letter = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { + key_char(*key).map(|c| (*key, c)) + } + _ => None, + }); + if let Some((key, c)) = letter + && let Some(row) = nav.row_with_mnemonic(c) + { + consume(Modifiers::NONE, key); + self.underline = true; + if let Some(cmd) = row.command.clone() { + *clicked = Some(cmd); + egui::Popup::close_all(ctx); + } else { + nav.open_sub_row(ctx, row.widget); + } + ctx.request_repaint(); + } + if tap { + egui::Popup::close_all(ctx); + self.underline = false; + ctx.request_repaint(); + } + return; + } + // No menu open: Alt (tapped) or F10 puts the keyboard on the bar or takes it off again. + let f10 = consume(Modifiers::NONE, Key::F10); + if tap || f10 { + self.focus = if self.focus.is_some() { None } else { Some(0) }; + self.underline = false; + ctx.request_repaint(); + return; + } + let Some(focus) = self.focus else { return }; + let press = |k: Key| consume(Modifiers::NONE, k); + if press(Key::Escape) { + self.focus = None; + } else if press(Key::ArrowRight) { + self.focus = Some((focus + 1) % n); + } else if press(Key::ArrowLeft) { + self.focus = Some((focus + n - 1) % n); + } else if press(Key::ArrowDown) || press(Key::Enter) || press(Key::Space) { + open_top(focus, self); + } else if let Some((key, c)) = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if modifiers.is_none() => key_char(*key).map(|c| (*key, c)), + _ => None, + }) { + if let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) { + consume(Modifiers::NONE, key); + open_top(i, self); + } + } else { + return; + } + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_are_unique_and_prefer_photoshop_s() { + let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; + let preferred: Vec> = TOP_PREFERRED.iter().map(|(_, c)| Some(*c)).collect(); + assert_eq!(assign(&labels, &preferred), "FEILYSTVWH".chars().map(Some).collect::>()); + // Items: first letters first, then the next free letter of the word starts, then any. + assert_eq!( + assign(&["New…", "Open…", "Open Recent", "Close", "Close All", "Save", "Save As…"], &[]), + [Some('N'), Some('O'), Some('R'), Some('C'), Some('A'), Some('S'), Some('V'),] + ); + // A preferred letter the label lacks (a translation) falls back; a taken one too. + assert_eq!(assign(&["Datei", "Bearbeiten"], &[Some('F'), Some('E')]), [Some('D'), Some('E')]); + assert_eq!(assign(&["Aa", "Ab", "Ba"], &[Some('A'), Some('A'), Some('A')]), [Some('A'), Some('B'), None]); + // Only ASCII letters and digits are keys: no mnemonic without one. + assert_eq!(assign(&["ファイル", "Zoom 100%", "✔ Rulers"], &[]), [None, Some('Z'), Some('R')]); + } + + #[test] + fn key_chars_are_plain_letters_and_digits() { + assert_eq!(key_char(Key::A), Some('A')); + assert_eq!(key_char(Key::Num7), Some('7')); + assert_eq!(key_char(Key::F10), None); + assert_eq!(key_char(Key::ArrowDown), None); + assert_eq!(key_char(Key::Space), None); + } +} + +#[cfg(test)] +#[path = "menu_keys_tests.rs"] +mod harness_tests; diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs new file mode 100644 index 0000000000..1f226054e0 --- /dev/null +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -0,0 +1,212 @@ +//! The menu bar from the keyboard (#4988): Alt / F10 focus the bar, Alt+letter opens a menu, +//! letters run rows, and the shortcuts wait while the bar has the keyboard. +use crate::PhotocraftApp; +use crate::menu_keys::Keys; +use crate::menu_nav::{Nav, is_open}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.sync_views(); + app +} + +/// The menu bar alone, as `menu_nav_tests` drives it. +fn bar() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) { + h.key_press(k); + h.run_steps(3); +} + +fn alt_key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) { + h.event_modifiers(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }, egui::Modifiers::ALT); + h.run_steps(3); +} + +/// A tap on Alt: down, then up with nothing else pressed between. +fn alt_tap(h: &mut Harness<'static, PhotocraftApp>) { + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); +} + +fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec { + Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default() +} + +#[test] +fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() { + let mut h = bar(); + assert_eq!(Keys::current(&h.ctx).focus, None); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File"); + assert!(crate::menu_keys::is_focused(&h.ctx), "the shortcuts wait for the bar"); + assert!(!is_open(&h.ctx), "no menu opens yet"); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ moves to Edit"); + key(&mut h, egui::Key::ArrowLeft); + key(&mut h, egui::Key::ArrowLeft); + assert_eq!(Keys::current(&h.ctx).focus, Some(9), "← wraps round to Help"); + key(&mut h, egui::Key::ArrowRight); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1)); + key(&mut h, egui::Key::ArrowDown); + h.run_steps(2); + assert!(is_open(&h.ctx), "↓ opens the lit menu"); + assert!(h.query_by_label_contains("Undo").is_some(), "Edit is open"); + assert_eq!(Keys::current(&h.ctx).focus, None, "the focus moves into the menu"); + let first = rows(&h, 0).iter().position(|r| r.enabled).unwrap(); + assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(first), "its first row is lit, as on Windows"); + assert!(Keys::current(&h.ctx).underline, "a menu opened from the keyboard shows its mnemonics"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar"); +} + +#[test] +fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() { + let mut h = bar(); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0)); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, None, "a second tap leaves"); + key(&mut h, egui::Key::F10); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar too"); + key(&mut h, egui::Key::Escape); + assert_eq!(Keys::current(&h.ctx).focus, None); + assert!(!crate::menu_keys::is_focused(&h.ctx)); + key(&mut h, egui::Key::F10); + let at = egui::pos2(600.0, 300.0); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "a click anywhere leaves"); +} + +#[test] +fn alt_with_a_letter_opens_that_menu_and_enter_opens_the_lit_one() { + let mut h = bar(); + alt_key(&mut h, egui::Key::E); + h.run_steps(2); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "Alt+E opens Edit"); + alt_key(&mut h, egui::Key::F); + h.run_steps(2); + assert!(h.query_by_label_contains("Open…").is_some(), "Alt+F switches to File while a menu is open"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx)); + key(&mut h, egui::Key::F10); + key(&mut h, egui::Key::Enter); + h.run_steps(2); + assert!(h.query_by_label_contains("Open…").is_some(), "Enter opens the lit File menu"); + key(&mut h, egui::Key::Escape); + key(&mut h, egui::Key::F10); + key(&mut h, egui::Key::V); + h.run_steps(2); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Zoom In").is_some(), "a letter on the focused bar opens that menu (V = View)"); +} + +#[test] +fn a_letter_in_an_open_menu_runs_the_row_with_that_mnemonic_or_opens_its_submenu() { + let mut h = bar(); + alt_key(&mut h, egui::Key::V); + h.run_steps(2); + assert!(h.query_by_label_contains("Zoom In").is_some(), "View is open"); + let level = rows(&h, 0); + let zoom_in = level.iter().find(|r| r.command.as_deref() == Some("view.zoomIn")).expect("Zoom In row"); + let letter = zoom_in.mnemonic.expect("Zoom In has a mnemonic"); + assert!(level.iter().filter(|r| r.mnemonic == Some(letter)).count() == 1, "mnemonics are unique within a level"); + let zoom = h.state().ui.views[0].zoom; + key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap()); + h.run_steps(3); + assert!(h.state().ui.views[0].zoom > zoom, "the letter ran Zoom In ({letter})"); + assert!(!is_open(&h.ctx), "and closed the menu"); + // A submenu's letter opens it, with its first row lit. + alt_key(&mut h, egui::Key::V); + h.run_steps(2); + let level = rows(&h, 0); + let sub = level.iter().find(|r| r.command.is_none() && r.enabled && r.mnemonic.is_some()).expect("an enabled submenu with a mnemonic"); + let letter = sub.mnemonic.unwrap(); + key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap()); + h.run_steps(3); + assert!(!rows(&h, 1).is_empty(), "the submenu opened ({letter})"); + assert_eq!(Nav::current(&h.ctx).level, 1); +} + +#[test] +fn mnemonics_are_underlined_only_with_alt_or_a_keyboard_opened_menu() { + let mut h = bar(); + h.get_by_label("File").click(); + h.run_steps(4); + assert!(is_open(&h.ctx)); + assert!(!Nav::current(&h.ctx).underline, "a menu opened with the mouse shows no underlines"); + h.key_down(egui::Key::AltLeft); + h.run_steps(3); + assert!(Nav::current(&h.ctx).underline, "while Alt is held they show"); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + // The tap closed the menu (as on Windows); opening from the keyboard underlines. + assert!(!is_open(&h.ctx), "an Alt tap closes an open menu"); + alt_key(&mut h, egui::Key::F); + h.run_steps(2); + assert!(is_open(&h.ctx) && Nav::current(&h.ctx).underline); + assert!(rows(&h, 0).iter().any(|r| r.mnemonic.is_some()), "rows carry their letters"); +} + +#[test] +fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let t = crate::menu_keys::mnemonic_text(ui, "✔ ", "Save As…", Some('A'), None); + let egui::WidgetText::LayoutJob(job) = t else { panic!("a mnemonic makes a layout job") }; + assert_eq!(job.text, "✔ Save As…"); + let underlined: Vec<&str> = + job.sections.iter().filter(|s| s.format.underline != egui::Stroke::NONE).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect(); + assert_eq!(underlined, ["a"], "the first occurrence, case-insensitively"); + let t = crate::menu_keys::mnemonic_text(ui, "", "Edit", None, None); + assert!(matches!(t, egui::WidgetText::RichText(_)), "without a mnemonic, plain text"); + let t = crate::menu_keys::mnemonic_text(ui, "", "Édition", Some('D'), None); + let egui::WidgetText::LayoutJob(job) = t else { panic!() }; + assert_eq!(job.sections.len(), 3, "multi-byte text before the letter is kept whole"); + }); + out.textures_delta.clear(); +} + +/// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the +/// arrows don't nudge the layer. +#[test] +fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = app(); + app.services.native_menu = None; + app + }); + h.run_steps(4); + let tool = h.state().ui.tool; + key(&mut h, egui::Key::F10); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar"); + key(&mut h, egui::Key::E); + h.run_steps(2); + assert_eq!(h.state().ui.tool, tool, "E did not pick the Eraser"); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "E opened Edit"); + key(&mut h, egui::Key::Escape); + h.run_steps(2); + assert!(!is_open(&h.ctx)); + h.key_press(egui::Key::E); + h.run_steps(3); + assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); +} diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 7924fa8deb..822fcae2bd 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -50,13 +50,17 @@ pub struct Row { pub command: Option, /// Where it was drawn (scrolled out of view when outside its level's `views` rect). pub rect: Rect, + /// The letter that runs it from the keyboard ([`crate::menu_keys`]), upper case. + pub mnemonic: Option, } /// Keyboard / highlight state of the open menu. #[derive(Clone, Debug, Default)] pub struct Nav { /// The open top-level menu (index into the menu bar). - top: Option, + pub(crate) top: Option, + /// Underline the rows' mnemonics this frame ([`crate::menu_keys`]). + pub underline: bool, /// Popup ids of the top-level menus, from the last frame. tops: Vec, /// The level the arrow keys move in (0 = the top-level menu). @@ -146,7 +150,15 @@ impl Nav { /// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the /// pointer) is on it, and register it for the keys. `add` returns the row's response. A row /// without a `command` opens a submenu. - pub fn row(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R { + pub fn row( + &mut self, + ui: &mut Ui, + level: usize, + enabled: bool, + command: Option<&str>, + mnemonic: Option, + add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R), + ) -> R { let i = self.rows.get(level).map_or(usize::MAX, Vec::len); // While a submenu is kept open for the pointer heading to it, its row stays lit and the // rows crossed on the way don't light up. @@ -166,7 +178,7 @@ impl Nav { self.aim(level, i, root, open, &r); } if let Some(rows) = self.rows.get_mut(level) { - rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect }); + rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect, mnemonic }); if let Some(index) = held { self.set_hi(level, Some(index)); } else { @@ -278,6 +290,24 @@ impl Nav { } /// Open the highlighted submenu of `level` and move the keyboard into it. + /// Light the first enabled row of `level` once it has been drawn (a menu opened from the + /// keyboard, as on Windows). + pub(crate) fn highlight_first(&mut self, level: usize) { + self.first_at = Some(level); + } + + /// The enabled row of the level the arrows are in whose mnemonic is `c`. + pub(crate) fn row_with_mnemonic(&self, c: char) -> Option { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.rows.get(level)?.iter().find(|r| r.enabled && r.mnemonic == Some(c)).cloned() + } + + /// Open the submenu of row `widget` of the level the arrows are in. + pub(crate) fn open_sub_row(&mut self, ctx: &egui::Context, widget: Id) { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.open_sub(ctx, level, widget); + } + fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) { let Some(&root) = self.roots.get(level) else { return }; let sub = SubMenu::id_from_widget_id(widget); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..7f47e6ea64 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -53,6 +53,9 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("view.extras", "Extras", &["View"], Some("Cmd+H")), ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), + // Photoshop's interface brightness shortcuts: step through the four Pro themes. + ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), + ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), ("type.editText", "Edit Type", &[], None), ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), @@ -67,6 +70,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.toggle.properties", "Properties", &["Window"], None), ("window.toggle.color", "Color", &["Window"], Some("F6")), ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), + ("window.toggle.brushes", "Brushes", &["Window"], None), ("window.toggle.navigator", "Navigator", &["Window"], None), ("window.toggle.toolbar", "Tools", &["Window"], None), // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. @@ -116,7 +120,8 @@ pub(crate) fn panel_alias(id: &str) -> Option<&'static str> { "navigator" | "info" | "histogram" => "window.toggle.navigator", "tools" => "window.toggle.toolbar", "options" => "window.toggle.options", - "brushSettings" | "brushes" => "window.toggle.brushSettings", + "brushSettings" => "window.toggle.brushSettings", + "brushes" => "window.toggle.brushes", _ => return None, }) } @@ -164,7 +169,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va /// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it. pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { if let Some(tool) = crate::tool_shortcuts::tool(id) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); return Ok(json!({"tool": tool})); } if id == "ui.symmetryTransform" { @@ -224,12 +229,6 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::plugin_ui::menu(app, id, ¶ms) { return r; } - if id == "window.panel.brushes" { - // Window › Brushes opens the Brush Settings window on its presets tab. - app.ui.panels.brush_settings = true; - app.ui.brush_tab = 1; - return Ok(Value::Null); - } if id == "window.panel.brushSettings" { app.ui.brush_tab = 0; } @@ -325,7 +324,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: crate::notices::post(app, title, lines, false, None); app.save_as_with(None, tga_bits) } - _ => app.save_as_with(None, tga_bits), + // A flat file asks for its name; confirming its own file records a + // plain Save that writes the file an action is played on (#3531). + _ => app.save_dialog_flow(None, tga_bits, true), } } } @@ -374,6 +375,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: crate::prefs_ui::cycle_appearance(app, ctx); Ok(Value::Null) } + "view.darkenInterface" | "view.lightenInterface" => { + crate::prefs_ui::step_brightness(app, ctx, id == "view.lightenInterface")?; + Ok(json!({"theme": app.ui.theme.id()})) + } "window.theme.system" => { app.run("prefs.set", json!({"path": "interface.appearanceMode", "value": "auto"}))?; crate::prefs_ui::sync_appearance(app, ctx); @@ -418,6 +423,23 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "file.export.exportAs" => Ok(json!({"dialog": crate::export_dialog::open(app)?})), "file.export.quickExportAsPng" => crate::export_dialog::quick_export_png(app), + // Render Video exports a sequence of frames into a folder, not a named file. + // Keep explicit directory arguments from automation and the control channel unchanged. + "file.export.renderVideo" if params.get("dir").is_none() => { + #[cfg(target_arch = "wasm32")] + { + Err("Render Video to a local directory is unavailable on the web".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let doc = app.session.active().ok_or("no document")?.doc.id; + let mut render_params = params.as_object().cloned().unwrap_or_default(); + app.ask_folder(move |app, dir| { + render_params.insert("dir".into(), json!(dir)); + app.with_document(doc, |app| app.run("file.export.renderVideo", Value::Object(render_params))) + }) + } + } // Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer. l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; @@ -674,6 +696,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "toolbar" => &mut p.toolbar, "options" => &mut p.options_bar, "brushSettings" => &mut p.brush_settings, + "brushes" => &mut p.brushes, "dock" => &mut p.dock, _ => return Err(format!("unknown panel in {t}")), }; @@ -750,6 +773,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { i if i.starts_with(DOCUMENT_ITEM) => true, i if crate::links::url_for(i).is_some() => true, i if i.starts_with("window.theme.") => true, + // Interface brightness works with or without a document, as in Photoshop. + "view.darkenInterface" | "view.lightenInterface" => true, "file.save" | "file.saveAs" | "file.export.exportAs" | "file.export.quickExportAsPng" => { app.session.active().is_some() && app.services.export.is_some() } @@ -881,6 +906,7 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { "window.togglePanels" => p.toolbar || p.options_bar || p.dock, "window.toggle.options" => p.options_bar, "window.toggle.brushSettings" => p.brush_settings, + "window.toggle.brushes" => p.brushes, _ => return None, }) } @@ -1111,7 +1137,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } } if !prefs.menus.hidden.is_empty() { - items.retain(|i| !prefs.menus.hidden.contains(&i.id)); + items.retain(|i| !prefs.menus.is_hidden(&i.id)); } if prefs.interface.show_menu_colors { for it in &mut items { @@ -1178,6 +1204,13 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let mut clicked: Option = None; let t = crate::theme::Tokens::get(ui.ctx()); let mut nav = crate::menu_nav::Nav::load(ui.ctx()); + // The keyboard on the bar (#4988): Alt / F10 focus, Alt+letter, mnemonics. + let mut keys = crate::menu_keys::Keys::load(ui.ctx()); + keys.take_first_row(&mut nav); + nav.underline = keys.show_underlines(ui.ctx()); + let top_labels: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); + let preferred: Vec> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect(); + let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::>(), &preferred); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let v = &mut ui.style_mut().visuals; @@ -1192,10 +1225,21 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0); nav.bar_bottom = Some(ui.max_rect().bottom()); let mut buttons = Vec::with_capacity(TOP_MENUS.len()); - for top in TOP_MENUS { + for (k, top) in TOP_MENUS.iter().enumerate() { // egui's menu_button toggles on release; these titles open on the press (one // gesture can press, drag to an item and release), so each drives its popup. - let title = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(lang, top)).color(t.text_dim))); + // The title the keyboard is on (Alt / F10) is lit like a hovered one. + let focused = keys.focus == Some(if rtl { TOP_MENUS.len() - 1 - k } else { k }); + let label = if nav.underline { + crate::menu_keys::mnemonic_text(ui, "", &top_labels[k], top_mnemonics[k], Some(t.text_dim)) + } else { + egui::RichText::new(top_labels[k].as_str()).color(t.text_dim).into() + }; + let mut button = egui::Button::new(label); + if focused { + button = button.fill(ui.visuals().widgets.hovered.weak_bg_fill); + } + let title = ui.add(button); // The bar's close behaviour and style, as a menu (not bar) config so submenus inside // render as submenus. let bar = egui::containers::menu::MenuConfig::find(ui); @@ -1206,7 +1250,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let opening = title.clicked() && ui.ctx().data(|d| d.get_temp::(press_gesture_id())).unwrap_or(false); let close = if opening { egui::PopupCloseBehavior::IgnoreClicks - } else if top == "Help" { + } else if *top == "Help" { // Menus close on any click, inside them too; a click in Help's search field // must keep it open. Its items close it themselves when chosen. egui::PopupCloseBehavior::CloseOnClickOutside @@ -1228,12 +1272,12 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { ItemCache { state, items } }) .items; - let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); + let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(*top)).collect(); ui.set_min_width(220.0); if mine.is_empty() { ui.weak(crate::i18n::tr(lang, "(coming soon)")); } - if top == "Help" { + if *top == "Help" { // The search field scrolls with the rows, as part of the menu's content. crate::menu_nav::level(ui, 1, &mut nav, |ui, nav| { help_search(ui, items, opening, &mut clicked, nav); @@ -1253,8 +1297,14 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { tops.reverse(); } nav.keys(ui.ctx(), &tops, &mut clicked); + let mut mnemonics = top_mnemonics.clone(); + if rtl { + mnemonics.reverse(); + } + keys.keys(ui.ctx(), &tops, &mnemonics, &mut nav, &mut clicked); }); }); + keys.store(ui.ctx()); nav.store(ui.ctx()); // No menu open this frame: nothing is kept. app.menu_cache = built.into_inner(); @@ -1409,7 +1459,7 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, clicked: &m if let Some(sc) = &it.shortcut { b = b.shortcut_text(crate::shortcuts::pretty(sc)); } - let hit = nav.row(ui, 0, it.enabled, Some(&it.id), |ui, _| { + let hit = nav.row(ui, 0, it.enabled, Some(&it.id), None, |ui, _| { let r = ui.add_enabled(it.enabled, b); let hit = r.clicked() || released_on(ui, &r); (r, hit) @@ -1444,6 +1494,27 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click crate::widgets::style_spectrum_popup_menu(ui); // Walk in Photoshop order: leaves and separators at this depth; a submenu appears at the position // of its first child. + // Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels + // and the submenus' names as shown. + let shown_labels: Vec = { + let mut subs: Vec<&str> = Vec::new(); + items + .iter() + .filter_map(|it| { + if it.path.len() == depth { + (it.label != "---").then(|| translated_menu_label(lang, it)) + } else { + let name = it.path.get(depth).map(String::as_str)?; + (!subs.contains(&name)).then(|| { + subs.push(name); + crate::i18n::tr(lang, name).to_string() + }) + } + }) + .collect() + }; + let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::>(), &[]); + let mut next_row = 0usize; let mut shown_subs: Vec<&str> = Vec::new(); let mut last_was_sep = true; for (i, it) in items.iter().enumerate() { @@ -1455,10 +1526,16 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } continue; } - let mut text = translated_menu_label(lang, it); - if let Some(c) = it.checked { - text = format!("{} {}", if c { "✔" } else { " " }, text); - } + let label = translated_menu_label(lang, it); + let prefix = match it.checked { + Some(true) => "✔ ", + Some(false) => " ", + None => "", + }; + let mnemonic = mnemonics.get(next_row).copied().flatten(); + next_row += 1; + let text: egui::WidgetText = + if nav.underline { crate::menu_keys::mnemonic_text(ui, prefix, &label, mnemonic, None) } else { format!("{prefix}{label}").into() }; let mut b = egui::Button::new(text); if let Some(tint) = it.color.as_deref().and_then(menu_tint) { b = b.fill(tint.gamma_multiply(0.55)); @@ -1466,7 +1543,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click if let Some(sc) = &it.shortcut { b = b.shortcut_text(crate::shortcuts::pretty(sc)); } - let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| { + let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), mnemonic, |ui, _| { let r = ui.add_enabled(it.enabled, b); let r = match it.id.as_str() { "image.mode.bits8" | "image.mode.bits16" => r.on_hover_text(crate::i18n::tr(lang, "Integer")), @@ -1489,9 +1566,16 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click shown_subs.push(name); let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect(); let enabled = submenu_enabled(&child); + let mnemonic = mnemonics.get(next_row).copied().flatten(); + next_row += 1; + let title: egui::WidgetText = if nav.underline { + crate::menu_keys::mnemonic_text(ui, "", crate::i18n::tr(lang, name), mnemonic, None) + } else { + crate::i18n::tr(lang, name).into() + }; ui.add_enabled_ui(enabled, |ui| { - nav.row(ui, depth - 1, enabled, None, |ui, nav| { - let r = ui.menu_button(crate::i18n::tr(lang, name), |ui| render_level(ui, &child, depth + 1, clicked, nav)); + nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { + let r = ui.menu_button(title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); if r.inner.is_some() { // Where the submenu hangs from, to keep it below the menu bar (#319). nav.set_anchor(depth, r.response.rect); diff --git a/crates/ui-egui/src/monitor_status.rs b/crates/ui-egui/src/monitor_status.rs index 8e29b8c2f2..2350e6dc30 100644 --- a/crates/ui-egui/src/monitor_status.rs +++ b/crates/ui-egui/src/monitor_status.rs @@ -218,19 +218,24 @@ pub fn apply(app: &mut PhotocraftApp, r: Detection) { }) .collect() }; - for (id, name, reason) in fallbacks { - if app.monitors.reported.iter().any(|(i, r)| *i == id && *r == reason) { - continue; - } - app.monitors.reported.push((id, reason.clone())); - let template = if name.is_empty() { - "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - } else { - "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - }; - let lines = vec![reason, crate::i18n::fmt(tl!(template), &[("display", &name)])]; - crate::notices::post(app, FALLBACK_TITLE, lines, true, None); + let fresh: Vec<_> = fallbacks.into_iter().filter(|(id, _, reason)| !app.monitors.reported.iter().any(|(i, r)| i == id && r == reason)).collect(); + if fresh.is_empty() { + return; } + app.monitors.reported.extend(fresh.iter().map(|(id, _, reason)| (*id, reason.clone()))); + // One notice per reading, not one per display: two profile-less monitors (common on Linux + // desks) gave two cards saying the same thing at every launch. Each display keeps its own + // reason line; the names share the existing template, so no catalog change is needed. + let names: Vec<&str> = fresh.iter().map(|(_, name, _)| name.as_str()).filter(|n| !n.is_empty()).collect(); + let template = if names.is_empty() { + "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + } else { + "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + }; + let advice = crate::i18n::fmt(tl!(template), &[("display", &names.join(", "))]); + let mut lines: Vec = fresh.into_iter().map(|(_, _, reason)| reason).collect(); + lines.push(advice); + crate::notices::post(app, FALLBACK_TITLE, lines, true, None); } /// The frame of the window `ctx` draws, in OS points (as `Display::frame`). egui reports it in @@ -427,6 +432,29 @@ mod tests { assert!(lines[0].starts_with("Monitor profile on Built-in Retina Display: sRGB"), "{lines:?}"); } + #[test] + fn displays_without_profiles_share_one_notice() { + // Two profile-less monitors (a Linux desk: DP-4 and DP-5) gave two identical cards. + let mut app = app(); + let desk = |id, name: &str, x| Display { id, name: name.into(), frame: [x, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }; + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + let lines = &app.ui.notices[0].lines; + assert_eq!(lines[0], "DP-4 has no ICC profile"); + assert_eq!(lines[1], "DP-5 has no ICC profile"); + assert!(lines[2].starts_with("The canvas on DP-4, DP-5 is shown as sRGB."), "{lines:?}"); + // The same reading again: nothing new to say. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + // A third display plugged in later: a card for it alone. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0), desk(3, "HDMI-A-1", 3840.0)])); + assert_eq!(app.ui.notices.len(), 2); + let lines = &app.ui.notices[1].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "HDMI-A-1 has no ICC profile"); + assert!(lines[1].starts_with("The canvas on HDMI-A-1 is shown"), "{lines:?}"); + } + #[test] fn displays_are_read_again_when_they_may_have_changed() { let mut app = app(); diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs index 0db1a5c80d..c3d07f313d 100644 --- a/crates/ui-egui/src/move_hover.rs +++ b/crates/ui-egui/src/move_hover.rs @@ -95,6 +95,11 @@ pub(crate) fn active_outline(app: &mut PhotocraftApp, at: Option<[f64; 2]>, comm app.canvas_hover_layer = None; return None; } + // A live rectangle's corner widget under the pointer (or being dragged) isn't a layer to pick. + if at.is_some_and(|p| crate::corner_radius::hovers(app, p)) { + app.canvas_hover_layer = None; + return None; + } // A layer hovered in the Layers panel takes precedence over canvas rollover. if let Some(id) = app.hover_layer && app.drag.is_none() @@ -180,7 +185,7 @@ mod tests { assert_eq!(active_outline(&mut app, None, false, false), Some(a)); // When a drag or transform is in progress, no outline: - app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0 }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index a9e5eeb782..64b3a65603 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -8,7 +8,8 @@ //! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one //! history step ("Duplicate + Move"). ⇧⌥ combines both. //! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free -//! Transform is active they nudge the box instead. +//! Transform is active they nudge the box instead. A run of nudges is one History state +//! (`nudge_bundle`, #2259). //! - With a selection the drag and the arrow keys move the selected pixels (`move_ui`): ⇧ locks //! the drag to multiples of 45° and ⌥ copies the pixels instead of duplicating the layer. //! @@ -185,6 +186,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] { if ctx.input_mut(|i| i.consume_key(base | mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; + app.nudge_bundle.now = ctx.input(|i| i.time); nudge(app, ux * k, uy * k, mods.alt); return true; } @@ -218,13 +220,12 @@ fn brush_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } /// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)` -/// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). +/// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). Nudges that follow +/// each other are one History state, or one Undo step of the box (`nudge_bundle`); the selected +/// pixels float until dropped, which is one state already. pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { - if let Some(t) = app.ui.transform.as_mut() { - if t.warp.is_none() { - t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]); - t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy]; - } + if app.ui.transform.is_some() { + crate::transform_tool::nudge(app, dx, dy); return; } if app.drag.is_some() { @@ -235,7 +236,14 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { return; } let from = if duplicate_first { duplicate(app) } else { None }; - if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) { + let moved = if duplicate_first { + // A copy is a state of its own, folded with its move below. + app.nudge_bundle.reset(); + app.run("layer.translate", json!({"dx": dx, "dy": dy})) + } else { + crate::nudge_bundle::run(app, "layers", "layer.translate", json!({"dx": dx, "dy": dy})) + }; + if let Err(e) = moved { app.ui.status = e; } if let Some(from) = from { @@ -244,9 +252,10 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { } /// Arrow keys with a selection tool (#1428), as in Photoshop: nudge the selection outline 1 px -/// (⇧ 10 px) through `select.transformSelection`, one history step per press; the pixels stay -/// put. A floating piece (⌘-dragged pixels) moves instead, like a plain drag on it. Not while a -/// drag, a polygon or a Magnetic Lasso border is in progress. Returns true when a key was used. +/// (⇧ 10 px) through `select.transformSelection`, a run of presses being one history step +/// (`nudge_bundle`); the pixels stay put. A floating piece (⌘-dragged pixels) moves instead, like +/// a plain drag on it. Not while a drag, a polygon or a Magnetic Lasso border is in progress. +/// Returns true when a key was used. fn selection_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if !crate::tool_feedback::is_selection_tool(app.ui.tool) || app.drag.is_some() || !app.ui.polygon.is_empty() || app.ui.magnetic.active() { return false; @@ -263,8 +272,14 @@ fn selection_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if ctx.input_mut(|i| i.consume_key(mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; let (dx, dy) = (ux * k, uy * k); - let id = if floating { "select.float" } else { "select.transformSelection" }; - if let Err(e) = app.run(id, json!({"dx": dx, "dy": dy})) { + app.nudge_bundle.now = ctx.input(|i| i.time); + // A floating piece is no state until dropped; the outline's nudges bundle. + let result = if floating { + app.run("select.float", json!({"dx": dx, "dy": dy})) + } else { + crate::nudge_bundle::run(app, "selection", "select.transformSelection", json!({"dx": dx, "dy": dy})) + }; + if let Err(e) = result { app.ui.status = e; app.ui.status_error = true; } @@ -420,8 +435,14 @@ mod tests { /// One key press through `arrow_keys`, as egui delivers it. fn press(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers) -> bool { + press_at(app, key, mods, 0.0) + } + + /// [`press`] at egui time `time` (seconds). + fn press_at(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers, time: f64) -> bool { let ctx = egui::Context::default(); ctx.input_mut(|i| { + i.time = time; i.modifiers = mods; i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }); }); @@ -540,11 +561,13 @@ mod tests { assert!(!press(&mut app, egui::Key::ArrowRight, CTRL)); } - /// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px), one step - /// each; the layer stays put. Without a selection they are left alone. + /// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px); the layer + /// stays put. Without a selection they are left alone. One step each with Bundle Arrow-Key + /// Nudges off (#2259). #[test] fn arrow_keys_nudge_the_selection_with_a_selection_tool() { let mut app = app_with_layer(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); let id = app.session.active().unwrap().active_layer.unwrap(); app.ui.tool = Tool::Lasso; assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "no selection: not used"); @@ -561,4 +584,197 @@ mod tests { app.ui.polygon = vec![[1.0, 1.0]]; assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); } + fn history(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() + } + + fn left_edge(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> i32 { + bounds(app, id).x0 + } + + /// #2259: tapping or holding an arrow key is one History state, so one Undo takes the whole + /// run back (Photoshop folds a run of nudges into one "Move"). + #[test] + fn a_run_of_arrow_nudges_is_one_history_step() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + // Twelve taps, 0.2 s apart, then ⇧ for ten more px: still the same run. + for i in 0..12 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 5.0 + 0.2 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34)); + assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert!(app.session.undo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); + assert!(app.session.redo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34), "and one Redo does it again"); + } + + /// A pause longer than Nudge Bundle Pause starts a new run; a longer pause setting keeps it. + #[test] + fn a_pause_ends_the_run() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 1.0); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 1.9); + assert_eq!(history(&app), h0 + 1, "0.9 s: the same run"); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 3.0); + assert_eq!(history(&app), h0 + 2, "1.1 s of quiet: a new run"); + app.session.edit_prefs(|p| p.tools.nudge_bundle_pause_ms = 5000); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 7.0); + assert_eq!(history(&app), h0 + 2, "4 s with a 5 s pause: the same run"); + assert_eq!(left_edge(&app, id), 12); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, id), 10, "Undo takes back the second run only"); + } + + /// With Bundle Arrow-Key Nudges off every press is a state, as before (#2259). + #[test] + fn every_press_is_a_step_with_bundling_off() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + let h0 = history(&app); + for _ in 0..5 { + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + } + assert_eq!(history(&app), h0 + 5); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, id), 12, "one pixel back"); + // Turning it back on bundles the next run, which doesn't swallow the earlier steps. + app.session.edit_prefs(|p| p.tools.bundle_nudges = true); + for _ in 0..3 { + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + } + assert_eq!(history(&app), h0 + 5); + assert_eq!(left_edge(&app, id), 15); + } + + /// Any other change to the document ends the run: another edit, Undo, a different layer + /// selected, a save. + #[test] + fn other_changes_end_the_run() { + let mut app = app_with_layer(); + let a = app.session.active().unwrap().active_layer.unwrap(); + let tap = |app: &mut PhotocraftApp| assert!(press(app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + let h0 = history(&app); + tap(&mut app); + tap(&mut app); + assert_eq!(history(&app), h0 + 1); + // An edit of another kind between two nudges. + app.session.execute("layer.setProps", json!({"opacity": 0.5})).unwrap(); + tap(&mut app); + tap(&mut app); + assert_eq!(history(&app), h0 + 3, "nudges, the edit, nudges"); + // Undo ends it too: the next nudge is a state of its own, not folded into the one before. + assert!(app.session.undo()); + tap(&mut app); + assert_eq!(history(&app), h0 + 3); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, a), 10, "the new run was undone alone"); + // Saving keeps the saved state a step of its own. + tap(&mut app); + tap(&mut app); + let before = history(&app); + let st = app.session.active_mut().unwrap(); + st.saved_revision = st.revision; + tap(&mut app); + assert_eq!(history(&app), before + 1, "a save between two nudges ends the run"); + // Selecting another layer: the same keys now move that one, in a run of its own. + let b = { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + }; + assert_ne!(a, b); + let before = history(&app); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + tap(&mut app); + app.session.select_layer(a).unwrap(); + tap(&mut app); + assert_eq!(history(&app), before + 3, "paint, a nudge of the new layer, a nudge of the first"); + } + + /// ⌥ copies, so ⌥-nudges are never folded into a run (the copy is part of each step). + #[test] + fn alt_nudges_do_not_join_a_run() { + let mut app = app_with_layer(); + let h0 = history(&app); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + assert_eq!(history(&app), h0 + 3); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move"), "the run after the copy is a state of its own"); + } + + /// #2259: nudges of the selection outline bundle the same way. + #[test] + fn selection_outline_nudges_bundle_into_one_step() { + let mut app = app_with_layer(); + app.ui.tool = Tool::Lasso; + app.session.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap(); + let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + let h0 = history(&app); + for i in 0..5 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 2.0 + 0.1 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 2.6)); + assert_eq!(sel(&app), photocraft_geom::Rect::new(9, 14, 19, 24)); + assert_eq!(history(&app), h0 + 1); + assert!(app.session.undo()); + assert_eq!(sel(&app), photocraft_geom::Rect::new(4, 4, 14, 14), "Undo takes the outline back where it was drawn"); + // The layer nudge and the outline nudge are different runs even back to back. + app.session.execute("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + let h1 = history(&app); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 3.0); + assert_eq!(history(&app), h1 + 1); + } + + /// Free Transform: nudges of the box that follow each other are one Undo step of the session + /// (it has its own history); a drag or a pause starts another. + #[test] + fn free_transform_nudges_bundle_into_one_undo_step() { + let mut app = app_with_layer(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let quad = |app: &PhotocraftApp| app.ui.transform.as_ref().unwrap().quad; + let q0 = quad(&app); + let moved = |dx: f64, dy: f64| q0.map(|q| [q[0] + dx, q[1] + dy]); + let settle = |app: &mut PhotocraftApp| crate::transform_tool::track_steps(app, &ctx); + for i in 0..6 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 4.0 + 0.1 * f64::from(i))); + settle(&mut app); + } + assert_eq!(quad(&app), moved(6.0, 0.0)); + // A pause, and the next nudge is a step of its own. + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::NONE, 9.0)); + settle(&mut app); + assert_eq!( + quad(&app), + [[q0[0][0] + 6.0, q0[0][1] + 1.0], [q0[1][0] + 6.0, q0[1][1] + 1.0], [q0[2][0] + 6.0, q0[2][1] + 1.0], [q0[3][0] + 6.0, q0[3][1] + 1.0]] + ); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), moved(6.0, 0.0), "Undo takes back the late nudge only"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), q0, "and the next one the six that were bundled"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), q0, "nothing is left of the session to undo"); + // With bundling off every nudge is a step. + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + for i in 0..3 { + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 20.0 + 0.1 * f64::from(i)); + settle(&mut app); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), moved(2.0, 0.0)); + } } diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index c9bf902c67..e615620d4c 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -633,7 +633,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); @@ -645,7 +645,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map)| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + body(app, ui, f); + } + }, + (app, f), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let p = command_params(&h.state().1); + assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1), Some(1))); + } + /// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841. #[test] fn orientation_swap_keeps_the_typed_values() { diff --git a/crates/ui-egui/src/new_guide_ui.rs b/crates/ui-egui/src/new_guide_ui.rs index 8b8a209281..07a8aef7b5 100644 --- a/crates/ui-egui/src/new_guide_ui.rs +++ b/crates/ui-egui/src/new_guide_ui.rs @@ -13,6 +13,11 @@ //! Color lists Photoshop's guide colours (Cyan by default) and Custom…, whose swatch opens the //! Color Picker. The guide stores the chosen colour itself, so changing the preference guide //! colour later doesn't recolour it. +//! +//! Edit Selected Guides (the guide menu, `guide_select`) opens the same dialog on the selected +//! guide: its orientation, its position in the ruler unit and its colour. OK runs `view.editGuide`, +//! sending the colour only when it was changed, so a guide that uses the preference colour keeps +//! doing so. use photocraft_engine::edit_cmds::GUIDE_COLORS; use photocraft_engine::prefs::Unit; @@ -23,6 +28,11 @@ use crate::state::DialogKind; const MARK: &str = "__newGuide"; const COMMAND: &str = "view.newGuide"; +const EDIT_COMMAND: &str = "view.editGuide"; +/// In an Edit Selected Guides dialog: the guide being edited, as `{"orientation", "index"}`, and +/// the colour it opened with. +const EDIT: &str = "__edit"; +const INITIAL_COLOR: &str = "__initialColor"; const CUSTOM: &str = "custom"; /// New Guide's colour when nothing was remembered, as in Photoshop. const DEFAULT_COLOR: &str = "cyan"; @@ -100,6 +110,44 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { app.ui.open_dialog(DialogKind::Command, f) } +/// The list entry (and Custom… colour) showing `rgb`: a named colour when it is one, else Custom…. +fn color_choice(rgb: [u8; 3]) -> (String, String) { + let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]); + match GUIDE_COLORS.iter().find(|(.., c)| *c == rgb) { + Some((name, ..)) => (name.to_string(), hex), + None => (CUSTOM.to_string(), hex), + } +} + +/// Edit Selected Guides: the dialog on the selected guide, with its orientation, its position in +/// the current ruler unit and its colour (its own, or the preference colour it is drawn in). +pub fn open_edit(app: &mut PhotocraftApp) -> Result { + let (vertical, index) = crate::guide_select::selected(app).ok_or_else(|| "No guide is selected".to_string())?; + let (px, own) = app + .session + .active() + .and_then(|st| { + let g = &st.doc.guides; + let p = *if vertical { &g.vertical } else { &g.horizontal }.get(index)?; + Some((p, g.color(vertical, p))) + }) + .ok_or_else(|| "No guide is selected".to_string())?; + let pref = photocraft_engine::prefs::parse_hex(&app.session.prefs().guides_grid_and_slices.guide_color).unwrap_or([0x4a, 0xff, 0xff]); + let (color, custom) = color_choice(own.unwrap_or(pref)); + let orientation = if vertical { "vertical" } else { "horizontal" }; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(EDIT_COMMAND)); + f.insert("__label".into(), json!("Edit Selected Guides")); + f.insert(EDIT.into(), json!({"orientation": orientation, "index": index})); + f.insert("orientation".into(), json!(orientation)); + f.insert("position".into(), json!(in_ruler_unit(app, px, vertical))); + f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); + f.insert("color".into(), json!(color)); + f.insert("customColor".into(), json!(custom)); + Ok(app.ui.open_dialog(DialogKind::Command, f)) +} + pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); @@ -152,11 +200,23 @@ pub fn params(app: &PhotocraftApp, f: &Map) -> Value { }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; - json!({"orientation": orientation, "position": position, "color": color}) + match f.get(EDIT) { + Some(edit) => { + let mut p = json!({"orientation": edit["orientation"], "index": edit["index"], "newOrientation": orientation, "position": position}); + if f.get(INITIAL_COLOR).and_then(Value::as_str) != Some(color.as_str()) { + p["color"] = json!(color); + } + p + } + None => json!({"orientation": orientation, "position": position, "color": color}), + } } pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let params = params(app, f); + if f.contains_key(EDIT) { + return confirm_edit(app, f, ¶ms); + } match app.run(COMMAND, params.clone()) { Ok(result) => { // Remember where the guide landed (so it reopens in whatever the ruler unit is then), @@ -182,5 +242,30 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, params: &Value) -> Result { + match app.run(EDIT_COMMAND, params.clone()) { + Ok(result) => { + let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical"); + let index = if from == to { + params["index"].as_u64().and_then(|i| usize::try_from(i).ok()) + } else { + app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1)) + }; + if let Some(i) = index { + crate::guide_select::select(app, to, i); + } + Ok(result) + } + Err(e) => { + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(DialogKind::Command, again); + Err(e) + } + } +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/nudge_bundle.rs b/crates/ui-egui/src/nudge_bundle.rs new file mode 100644 index 0000000000..87746c7165 --- /dev/null +++ b/crates/ui-egui/src/nudge_bundle.rs @@ -0,0 +1,167 @@ +//! Arrow-key nudges that follow each other are one History state (#2259). +//! +//! Tapping or holding an arrow key nudges the selected layers (or the selection outline) one +//! pixel at a time. One state per pixel buries the History panel and makes one Undo take back a +//! single pixel, so a run of nudges of the same thing shares one `coalesce` key (the engine folds +//! edits with the same key into one step, see `Session::execute`) and Undo takes back the run. +//! +//! A run lasts while each nudge comes within the Tools › Nudge Bundle Pause of the one before it +//! (longer than a key's auto-repeat delay, so a held key is one run) and nothing else happened +//! to the document in between: another edit, an Undo or Redo, a different layer selected or a +//! save each end it. Tools › Bundle Arrow-Key Nudges off gives every press its own state. +//! +//! [`NudgeBundle`] only decides which run a nudge belongs to; it reads the clock from the key +//! handlers ([`NudgeBundle::now`]) and the document from the caller, so it is tested without egui. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use photocraft_doc::DocId; +use photocraft_engine::DocState; + +/// What a run of nudges needs to find unchanged to go on: the document, and its revisions (every +/// edit, Undo, Redo and layer selection moves `revision`, a save moves `saved_revision`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Seen { + doc: DocId, + revision: u64, + saved_revision: u64, +} + +impl Seen { + pub(crate) fn of(st: &DocState) -> Self { + Self { doc: st.doc.id, revision: st.revision, saved_revision: st.saved_revision } + } +} + +/// The latest nudge: the run it belongs to, what it nudged, when, and the document it left. +#[derive(Clone, Debug)] +struct Last { + run: u64, + what: String, + at: f64, + left: Seen, +} + +/// The run the arrow-key nudges belong to (one per app; a nudge always goes to the active +/// document, whose state it checks). +#[derive(Clone, Debug, Default)] +pub struct NudgeBundle { + /// egui time (seconds) of the key press being handled; the arrow-key handlers set it. + pub(crate) now: f64, + /// Runs started so far; a run's number is part of its `coalesce` key. + started: u64, + last: Option, +} + +impl NudgeBundle { + /// The run a nudge of `what` now joins: the latest one when it was `what` too, came at most + /// `pause` seconds ago and left the document as `seen`; else a new run. + pub(crate) fn join(&mut self, what: &str, seen: Seen, pause: f64) -> u64 { + if let Some(l) = &self.last + && l.what == what + && l.left == seen + && self.now >= l.at + && self.now - l.at <= pause + { + return l.run; + } + self.started += 1; + self.started + } + + /// The nudge of `run` ran and left the document as `left`. + pub(crate) fn record(&mut self, run: u64, what: &str, left: Seen) { + self.last = Some(Last { run, what: what.to_string(), at: self.now, left }); + } + + /// The next nudge starts a new run. + pub(crate) fn reset(&mut self) { + self.last = None; + } +} + +/// The longest pause (seconds) between two nudges of one run, or `None` when nudges aren't +/// bundled (Tools › Bundle Arrow-Key Nudges). +pub(crate) fn pause(app: &PhotocraftApp) -> Option { + let tools = &app.session.prefs().tools; + tools.bundle_nudges.then(|| f64::from(tools.nudge_bundle_pause_ms) / 1000.0) +} + +/// Run the nudge command `id` (`what` names the thing it nudges: "layers", "selection") as part +/// of the current run of nudges, or on its own when nudges aren't bundled. +pub(crate) fn run(app: &mut PhotocraftApp, what: &str, id: &str, mut params: Value) -> Result { + let Some(pause) = pause(app) else { + app.nudge_bundle.reset(); + return app.run(id, params); + }; + let Some(st) = app.session.active() else { return app.run(id, params) }; + // The same keys nudge whatever is selected: another selection is another run. + let what = format!("{what}:{:?}:{:?}", st.active_layer, st.selected_layers); + let run = app.nudge_bundle.join(&what, Seen::of(st), pause); + if let Some(object) = params.as_object_mut() { + object.insert("coalesce".into(), json!(format!("nudge:{run}"))); + } + let result = app.run(id, params); + match (&result, app.session.active()) { + (Ok(_), Some(st)) => app.nudge_bundle.record(run, &what, Seen::of(st)), + _ => app.nudge_bundle.reset(), + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seen(revision: u64) -> Seen { + Seen { doc: DocId(1), revision, saved_revision: 0 } + } + + fn at(b: &mut NudgeBundle, t: f64) { + b.now = t; + } + + #[test] + fn nudges_within_the_pause_share_a_run() { + let mut b = NudgeBundle::default(); + at(&mut b, 10.0); + let first = b.join("layers", seen(1), 1.0); + b.record(first, "layers", seen(2)); + at(&mut b, 10.9); + assert_eq!(b.join("layers", seen(2), 1.0), first, "0.9 s later is the same run"); + b.record(first, "layers", seen(3)); + // The pause counts from the latest nudge, so a long run goes on while the keys do. + at(&mut b, 11.8); + assert_eq!(b.join("layers", seen(3), 1.0), first); + b.record(first, "layers", seen(4)); + at(&mut b, 12.9); + assert_ne!(b.join("layers", seen(4), 1.0), first, "1.1 s of quiet ends the run"); + } + + #[test] + fn anything_else_ends_the_run() { + let mut b = NudgeBundle::default(); + at(&mut b, 1.0); + let run = b.join("layers", seen(1), 1.0); + b.record(run, "layers", seen(2)); + let new = |b: &mut NudgeBundle, what: &str, s: Seen| b.join(what, s, 1.0) != run; + assert!(new(&mut b, "layers", seen(3)), "an edit, Undo or Redo moved the revision"); + assert!(new(&mut b, "selection", seen(2)), "another thing is nudged"); + assert!(new(&mut b, "layers", Seen { doc: DocId(2), ..seen(2) }), "another document"); + assert!(new(&mut b, "layers", Seen { saved_revision: 2, ..seen(2) }), "a save keeps the saved state a step of its own"); + assert!(!new(&mut b, "layers", seen(2)), "nothing happened: the same run"); + // The clock never runs backwards in a real session; if it did, that is not a run. + at(&mut b, 0.5); + assert!(new(&mut b, "layers", seen(2))); + } + + #[test] + fn reset_ends_the_run() { + let mut b = NudgeBundle::default(); + let run = b.join("layers", seen(1), 1.0); + b.record(run, "layers", seen(2)); + b.reset(); + assert_ne!(b.join("layers", seen(2), 1.0), run); + } +} diff --git a/crates/ui-egui/src/nudge_bundle_tests.rs b/crates/ui-egui/src/nudge_bundle_tests.rs new file mode 100644 index 0000000000..d18bbddafa --- /dev/null +++ b/crates/ui-egui/src/nudge_bundle_tests.rs @@ -0,0 +1,123 @@ +//! #2259: arrow-key nudges through the whole app (panels, shortcuts, the real frame loop and its +//! clock) are one History state per run, and Undo takes the run back. + +use egui_kittest::Harness; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app + }); + h.run_steps(6); + h +} + +fn bounds(h: &Harness<'_, PhotocraftApp>) -> Rect { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() +} + +fn steps(h: &Harness<'_, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.past_len() +} + +fn nudge(h: &mut Harness<'_, PhotocraftApp>, key: egui::Key, frames: usize) { + h.key_press(key); + h.run_steps(frames); +} + +#[test] +fn tapping_an_arrow_key_ten_times_is_one_history_step() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + for _ in 0..10 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(bounds(&h).x0, start.x0 + 10); + assert_eq!(steps(&h), n0 + 1, "ten taps, one state"); + let entries = h.state().session.active().unwrap().history.entries(); + assert_eq!(entries.last().map(String::as_str), Some("Move")); + assert_eq!(entries.iter().filter(|e| *e == "Move").count(), 1); + h.state_mut().session.undo(); + assert_eq!(bounds(&h), start, "one Undo takes all ten pixels back"); +} + +#[test] +fn a_held_key_is_one_history_step() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + nudge(&mut h, egui::Key::ArrowDown, 1); + // The key stays down: the OS repeats it after a pause of about half a second. + h.run_steps(2); + for _ in 0..15 { + h.event(egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.event(egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(bounds(&h).y0, start.y0 + 16); + assert_eq!(steps(&h), n0 + 1); + h.state_mut().session.undo(); + assert_eq!(bounds(&h), start); +} + +#[test] +fn a_pause_starts_a_new_step_and_the_preference_sets_how_long() { + let mut h = harness(); + let n0 = steps(&h); + nudge(&mut h, egui::Key::ArrowRight, 1); + nudge(&mut h, egui::Key::ArrowRight, 1); + // The test clock runs a quarter of a second per frame: eight frames is two seconds of quiet. + h.run_steps(8); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 2, "two seconds of quiet end the run"); + h.state_mut().run("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": 5000})).unwrap(); + h.run_steps(8); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 2, "with a five second pause, two seconds of quiet go on with the run"); + h.run_steps(24); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 3, "six seconds of quiet end it"); +} + +#[test] +fn the_preference_turns_bundling_off_and_on_again() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + h.state_mut().run("prefs.set", json!({"path": "tools.bundleNudges", "value": false})).unwrap(); + for _ in 0..4 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(steps(&h), n0 + 4, "a state per press"); + h.state_mut().session.undo(); + assert_eq!(bounds(&h).x0, start.x0 + 3); + h.state_mut().run("prefs.set", json!({"path": "tools.bundleNudges", "value": true})).unwrap(); + for _ in 0..4 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(steps(&h), n0 + 4, "the next four bundle: 3 earlier states and one run"); + assert_eq!(bounds(&h).x0, start.x0 + 7); +} + +/// Bad values are rejected, not stored (the control channel and the Preferences dialog share the +/// same ranges). +#[test] +fn the_pause_preference_stays_within_its_range() { + let mut h = harness(); + for bad in [json!(0), json!(99), json!(10_001), json!("long"), json!(-5)] { + assert!(h.state_mut().run("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": bad.clone()})).is_err(), "{bad}"); + } + assert_eq!(h.state().session.prefs().tools.nudge_bundle_pause_ms, 1000); +} diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs index cd04a4fc5f..a4d93ca057 100644 --- a/crates/ui-egui/src/open_prompt.rs +++ b/crates/ui-egui/src/open_prompt.rs @@ -75,19 +75,33 @@ fn show(app: &mut PhotocraftApp) { let name = front.name.as_str(); let working = crate::i18n::fmt(tl!("Working: {working}"), &[("working", q.get("working").and_then(Value::as_str).unwrap_or("?"))]); let missing = q.get("missing").and_then(Value::as_bool) == Some(true); + // A mismatch names the two profiles on the choices they belong to (`prefs_ui::mismatch_body`). let message = if missing { let what = crate::i18n::fmt(tl!("The document “{name}” does not have an embedded {mode} profile."), &[("mode", mode), ("name", name)]); format!("{what}\n{working}") } else { - let what = crate::i18n::fmt( + crate::i18n::fmt( tl!("The document “{name}” has an embedded color profile that does not match the current {mode} working space."), &[("mode", mode), ("name", name)], - ); - let embedded = crate::i18n::fmt(tl!("Embedded: {embedded}"), &[("embedded", q.get("embedded").and_then(Value::as_str).unwrap_or("?"))]); - format!("{what}\n{embedded}\n{working}") + ) }; let action = q.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); - crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD); + let names = json!({ + "embedded": q.get("embedded").and_then(Value::as_str).unwrap_or("?"), + "working": q.get("working").and_then(Value::as_str).unwrap_or("?"), + "policyKey": policy_key(front.doc.mode), + }); + crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD, names); +} + +/// The Color Settings policy that answers this mode's files when Ask On Mismatch is off. +fn policy_key(mode: ColorMode) -> Option<&'static str> { + match photocraft_engine::color_cmds::mode_space(mode) { + photocraft_cms::ColorSpace::Rgb => Some("policyRgb"), + photocraft_cms::ColorSpace::Cmyk => Some("policyCmyk"), + photocraft_cms::ColorSpace::Gray => Some("policyGray"), + _ => None, + } } /// Whether `fields` are one of this module's prompts. @@ -115,6 +129,31 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result Result { + let Some(key) = app.held_opens.front().and_then(|h| policy_key(h.doc.mode)) else { return answer(app, Some(action)) }; + let policy = match action { + "convert" => "convert", + "discard" => "off", + _ => "preserve", + }; + app.run("edit.colorSettings", json!({key: policy, "askOnMismatch": false}))?; + let same = |h: &Held| policy_key(h.doc.mode) == Some(key) && h.question.as_ref().is_some_and(|q| q.get("missing").and_then(Value::as_bool) != Some(true)); + let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + let r = open(app, held, Some(action)); + while app.held_opens.front().is_some_and(same) { + if let Some(held) = app.held_opens.pop_front() { + // A failure is shown to the user (`open_failed`); the next file still opens. + let _ = open(app, held, Some(action)); + } + } + advance(app); + r +} + /// Opens a queued file, applying `action` (the prompt's answer) or else the Color Settings /// policy, then does what any open does afterwards. fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result { diff --git a/crates/ui-egui/src/options_hit_area_tests.rs b/crates/ui-egui/src/options_hit_area_tests.rs new file mode 100644 index 0000000000..d444f06473 --- /dev/null +++ b/crates/ui-egui/src/options_hit_area_tests.rs @@ -0,0 +1,199 @@ +//! #5052: the actual Brush options caption and field form one scrub target. +use crate::PhotocraftApp; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = crate::state::Tool::Brush; + app.session.tools.brush.hardness = 0.5; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn geometry(h: &Harness<'_, PhotocraftApp>) -> (Rect, Rect) { + let label = h.get_by_label("Hardness").rect(); + let field = h + .get_all_by_role(egui::accesskit::Role::SpinButton) + .map(|n| n.rect()) + .filter(|r| r.left() >= label.right()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap(); + (label, field) +} + +fn drag(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, dx: f32, mods: Modifiers) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + for i in 1..=4 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(at + vec2(dx * i as f32 / 4.0, 0.0)); + h.run(); + } + h.drop_at(at + vec2(dx, 0.0)); + h.run_steps(2); +} + +#[test] +fn options_hardness_label_drag_cannot_copy_selected_caption() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.left() + 2.0, l.center().y), 20.0, Modifiers::NONE); + assert!(!h.ctx.plugin::().lock().has_selection(), "scrubbing Hardness must not create label text selection"); + h.event(egui::Event::Copy); + h.step(); + assert!( + !h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(s) if s.contains("Hard"))), + "a scrub caption must not select text for copying" + ); + assert!(h.state().session.tools.brush.hardness > 0.5); +} + +#[test] +fn options_hardness_caption_upper_and_lower_margins_scrub() { + for dy in [-10.0, 10.0] { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, l.center() + vec2(0.0, dy), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "24pt caption margin dy={dy}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1, "one command for the whole gesture"); + } +} + +#[test] +fn options_hardness_gap_and_suffix_scrub() { + for suffix in [false, true] { + let mut h = harness(); + let (l, f) = geometry(&h); + let x = if suffix { f.right() + 8.0 } else { (l.right() + f.left() - 4.0) / 2.0 }; + drag(&mut h, pos2(x, l.center().y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "new region suffix={suffix}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + h.drop_at(at); + h.run_steps(2); +} + +#[test] +fn options_numeric_editing_and_focused_text_selection_keep_priority() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let id = h.ctx.memory(|m| m.focused()).expect("click focuses the numeric editor"); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), f.width() - 4.0, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "text drag keeps numeric focus"); + assert_eq!(h.state().session.tools.brush.hardness, 0.5, "selecting digits must not scrub"); + let state = egui::TextEdit::load_state(&h.ctx, id).expect("focused text state"); + assert!(!state.cursor.char_range().unwrap().is_empty(), "digit selection still works"); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("25+5".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!((h.state().session.tools.brush.hardness - 0.3).abs() < 0.0001); +} + +#[test] +fn options_expanded_hit_click_does_not_edit_or_request_focus() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let before_focus = h.ctx.memory(|m| m.focused()).expect("numeric editor focus"); + let before_journal = h.state().session.journal.len(); + let l = h.get_by_label("Hardness").rect(); + click(&mut h, l.center() + vec2(0.0, 10.0)); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + println!( + "focus_before={before_focus:?} focus_after={:?} value={} journal_before={before_journal} journal_after={}", + h.ctx.memory(|m| m.focused()), + h.state().session.tools.brush.hardness, + h.state().session.journal.len() + ); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(before_focus), "matches actual main: background retains numeric focus without acquiring it"); + assert_eq!(h.state().session.journal.len(), before_journal); +} + +#[test] +fn options_added_regions_preserve_modifiers_and_numeric_drag_rate() { + for (mods, dx, want) in [(Modifiers::SHIFT, 3.0, 0.8), (Modifiers::ALT, 20.0, 0.52)] { + let mut h = harness(); + let (l, f) = geometry(&h); + drag(&mut h, pos2(f.right() + 8.0, l.center().y), dx, mods); + assert!((h.state().session.tools.brush.hardness - want).abs() < 0.0001); + } + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, f.center(), 20.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.6).abs() < 0.0001, "numeric drag retains its own rate"); +} + +#[test] +fn options_disabled_eraser_caption_and_added_regions_do_nothing() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Eraser; + h.state_mut().ui.tool_options.eraser_mode = "block".into(); + h.run_steps(3); + let (l, f) = geometry(&h); + for at in [l.center(), pos2(f.right() + 8.0, l.center().y), l.center() + vec2(0.0, 10.0)] { + drag(&mut h, at, 12.0, Modifiers::NONE); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!(h.state().session.journal.is_empty()); + } +} + +#[test] +fn options_field_outer_top_and_bottom_margins_scrub() { + for top in [true, false] { + let mut h = harness(); + let (_, f) = geometry(&h); + let y = if top { f.top() - 0.5 } else { f.bottom() + 0.5 }; + drag(&mut h, pos2(f.center().x, y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "field margin top={top}"); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +#[test] +fn options_gesture_keeps_its_start_owner_when_crossing_regions() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.right() - 2.0, l.center().y), 24.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.74).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), -30.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.35).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); +} + +#[test] +fn options_tab_moves_from_hardness_to_opacity_without_a_caption_stop() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + h.key_press(egui::Key::Tab); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!((h.state().session.tools.brush.opacity - 0.99).abs() < 0.0001); +} diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index bdb5c41501..9e06a1ae68 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -10,6 +10,21 @@ use photocraft_raster::Surface; /// A boundary segment in document pixel coordinates (axis-aligned). pub type Segment = ([i32; 2], [i32; 2]); +/// Bound a whole-selection trace, including offscreen pixels carried into view by a transform. +/// Include grid alignment and the two padding cells in the budget; ordinary traces keep their step. +pub(crate) fn bounded_step(bounds: Rect, mut step: u32) -> u32 { + step = step.max(1); + let cells = |step: u32| { + let s = i64::from(step); + let axis = |lo: i32, hi: i32| ((i64::from(hi) + s - 1).div_euclid(s) - i64::from(lo).div_euclid(s) + 2).max(0) as u64; + axis(bounds.x0, bounds.x1).saturating_mul(axis(bounds.y0, bounds.y1)) + }; + while cells(step) > 1_048_576 { + step = step.saturating_mul(2); + } + step +} + /// Extract the boundary of `mask` (> 0.5 = selected) as merged horizontal and vertical segments. pub fn outline(mask: &Surface, bounds: Rect) -> Vec { outline_scaled(mask, bounds, 1) @@ -40,15 +55,22 @@ fn outline_scaled_impl(mask: &Surface, bounds: Rect, step: u32, parallel: bool) let mut grid = vec![false; w * h]; let half = step / 2; let sample_row = |gy: usize, row: &mut [bool]| { - let y = (gy0 + gy as i32) * step + half; - if y < bounds.y0 || y >= bounds.y1 { + let y0 = (gy0 + gy as i32) * step; + if y0 >= bounds.y1 || y0 + step <= bounds.y0 { return; } + // A coarse cell can overlap a one-pixel-wide selection even when its centre misses it. + // Sample the overlapping edge; padding cells still stay outside the mask. + let y = (y0 + half).clamp(bounds.y0, bounds.y1 - 1); let mut vals = vec![0.0f32; row.len()]; mask.sample_row_strided(y, gx0 * step + half, step, 0, &mut vals); for (gx, (cell, v)) in row.iter_mut().zip(vals).enumerate() { let x = (gx0 + gx as i32) * step + half; - *cell = x >= bounds.x0 && x < bounds.x1 && v > 0.5; + *cell = if x >= bounds.x0 && x < bounds.x1 { + v > 0.5 + } else { + x - half < bounds.x1 && x - half + step > bounds.x0 && mask.sample_channel(x.clamp(bounds.x0, bounds.x1 - 1), y, 0) > 0.5 + }; } }; #[cfg(not(target_arch = "wasm32"))] @@ -108,6 +130,33 @@ mod tests { use super::*; use photocraft_color::PixelFormat; + #[test] + fn whole_transform_trace_has_a_bounded_grid() { + assert_eq!(bounded_step(Rect::new(0, 0, 200, 150), 1), 1); + assert_eq!(bounded_step(Rect::new(0, 0, 6000, 6000), 1), 8); + for bounds in [Rect::new(-50_001, -50_003, 50_000, 50_000), Rect::new(-1_000_000, 0, 1_000_000, 1)] { + let step = i64::from(bounded_step(bounds, 1)); + let w = (i64::from(bounds.x1) + step - 1).div_euclid(step) - i64::from(bounds.x0).div_euclid(step) + 2; + let h = (i64::from(bounds.y1) + step - 1).div_euclid(step) - i64::from(bounds.y0).div_euclid(step) + 2; + assert!(w * h <= 1_048_576, "{bounds:?}: {w} × {h}"); + } + // Both distant pieces survive the coarse trace: it covers the whole selection, not the viewport. + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-50_000, -50_000, -49_488, -49_488), &[1.0]); + mask.fill_rect(Rect::new(49_488, 49_488, 50_000, 50_000), &[1.0]); + let bounds = mask.content_bounds(); + let lines = outline_scaled_serial(&mask, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -49_000)); + assert!(lines.iter().any(|(a, _)| a[0] > 49_000)); + let mut thin = Surface::new(PixelFormat::GRAY8); + thin.fill_rect(Rect::new(-1_000_000, 0, -999_488, 1), &[1.0]); + thin.fill_rect(Rect::new(999_488, 0, 1_000_000, 1), &[1.0]); + let bounds = thin.content_bounds(); + let lines = outline_scaled_serial(&thin, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -999_000), "thin left piece remains visible"); + assert!(lines.iter().any(|(a, _)| a[0] > 999_000), "thin right piece remains visible"); + } + #[test] fn rectangle_has_four_edges() { let mut m = Surface::new(PixelFormat::GRAY8); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index de56d6714c..a62ecafff3 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -25,6 +25,7 @@ const PANELS: &[(&str, &str, Group)] = &[ ("layers", "Layers", Group::Layers), ("channels", "Channels", Group::Layers), ("paths", "Paths", Group::Layers), + ("brushes", "Brushes", Group::Brushes), ]; #[derive(Clone, Default)] struct LegacyDrag { @@ -80,6 +81,7 @@ fn legacy_layout(app: &PhotocraftApp, all: bool) -> Layout { Group::Navigator => app.ui.dock_tabs.navigator, Group::History => app.ui.dock_tabs.history, Group::Layers => app.ui.dock_tabs.layers, + Group::Brushes => app.ui.dock_tabs.brushes, }; let active = labels.get(selected).and_then(|label| normalize(label)).and_then(|id| panels.iter().position(|p| p == id)).unwrap_or(0); if group == Group::Properties && !pro { @@ -174,12 +176,13 @@ pub(crate) fn command(app: &mut PhotocraftApp, id: &str, params: &Value) -> Opti .and_then(|action| apply_action(app, action)), ); } - if let Some(layout) = &app.ui.docking - && let Some(panel) = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize) - { - let showing = layout.contains(&panel.to_string()); + if let Some(panel) = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize) { + if app.ui.docking.is_none() && panel != "brushes" { + return None; + } + let showing = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel.to_string())); let show = match params.get("show").or_else(|| params.get("on")) { - None => !showing || !panel_active(layout, panel) || collapsed_members(app, layout).contains(panel), + None => !showing || app.ui.docking.as_ref().is_some_and(|layout| !panel_active(layout, panel) || collapsed_members(app, layout).contains(panel)), Some(value) => match value.as_bool() { Some(show) => show, None => return Some(Err("show must be a boolean".into())), @@ -605,6 +608,7 @@ pub(crate) fn rail_on(app: &PhotocraftApp, group: Group) -> bool { pub(crate) fn rail_click(app: &mut PhotocraftApp, group: Group) { let Some(layout) = app.ui.docking.as_ref() else { return }; + let floating = PANELS.iter().filter(|p| p.2 == group).any(|p| layout.floating.iter().any(|g| g.panels.iter().any(|id| id == p.0))); let existing = PANELS.iter().find(|p| p.2 == group && layout.contains(&p.0.to_owned())).map(|p| p.0); let panel = existing.or_else(|| { group @@ -614,7 +618,11 @@ pub(crate) fn rail_click(app: &mut PhotocraftApp, group: Group) { }); if let Some(panel) = panel { let result = if existing.is_some() && rail_on(app, group) { - group_command(app, &json!({"panel": panel, "operation":"collapse"})) + if floating { + group_command(app, &json!({"panel": panel, "operation":"close"})) + } else { + group_command(app, &json!({"panel": panel, "operation":"collapse"})) + } } else { change(app, "activate", &json!({"panel":panel})) }; @@ -792,6 +800,7 @@ pub(crate) fn prepare_legacy_update( Group::Navigator => tabs.navigator, Group::History => tabs.history, Group::Layers => tabs.layers, + Group::Brushes => tabs.brushes, }; let id = group.tabs(pro).get(index).and_then(|label| normalize(label)).ok_or("dockTabs index is outside the panel group")?; if visibility == Some(false) { @@ -905,6 +914,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { self.commands.push(("window.panel.group", json!({"panel":active,"operation":if collapsed {"expand"} else {"collapse"}}))); ui.close(); } + if ui.add_enabled(!locked, egui::Button::new(if floating { tl!("Dock Panel") } else { tl!("Float Panel") })).clicked() { + self.commands.push((if floating { "window.panel.dock" } else { "window.panel.float" }, json!({"panel":active}))); + ui.close(); + } if !floating { let groups = self.app.ui.docking.as_ref().map(root_groups).unwrap_or_default(); let index = groups.iter().position(|g| g.contains(active)); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 735274eb85..c6feeff633 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -30,6 +30,35 @@ fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_owners assert_eq!(restored.docking_hidden, app.ui.docking_hidden); } +#[test] +fn floating_panel_rail_toggle_closes_the_float_window() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"properties","x":50,"y":70,"width":330,"height":410})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels.contains(&"properties".into()))); + + rail_click(&mut app, Group::Properties); + + assert!(app.ui.docking.as_ref().unwrap().floating.iter().all(|g| !g.panels.contains(&"properties".into()))); + assert!(app.ui.docking.as_ref().unwrap().contains(&"adjustments".into())); + assert!(app.ui.panels.properties, "the docked Adjustments tab keeps the group visible"); +} + +#[test] +fn brushes_menu_action_uses_the_shared_docking_layout() { + let mut app = app(); + let before = app.ui.docking.clone(); + assert!(crate::menus::invoke(&mut app, &egui::Context::default(), "window.panel.brushes", json!({})).is_ok()); + let layout = app.ui.docking.as_ref().expect("brushes panel must be registered in docking"); + assert!(layout.contains(&"brushes".to_string())); + assert!(app.ui.panels.brushes); + assert_ne!(app.ui.docking, before); + + app.run("window.panel.float", json!({"panel":"brushes"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels.contains(&"brushes".into()))); + app.run("window.panel.dock", json!({"panel":"brushes"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels.contains(&"brushes".into()))); +} + #[test] fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { let mut app = app(); @@ -95,6 +124,24 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab assert!(valid(layout)); } +#[test] +fn brushes_tab_drag_floats_and_can_redock_into_another_tab_group() { + let mut app = app(); + crate::menus::invoke(&mut app, &egui::Context::default(), "window.panel.brushes", json!({})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Brushes").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + drag(&mut h, source, egui::pos2(160.0, 140.0)); + let layout = h.state().ui.docking.as_ref().expect("brushes drag starts shared docking"); + assert!(layout.floating.iter().any(|group| group.panels.contains(&"brushes".into())), "{layout:?}"); + + let source = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"brushes".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + drag(&mut h, source, destination); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert!(!layout.floating.iter().any(|group| group.panels.contains(&"brushes".into()))); + assert!(valid(layout)); +} + #[test] fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { let mut app = app(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 733f923c8a..769f5a8b9a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -90,18 +90,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .exact_size(w) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))) .show(ui, |ui| { - if t.pro { - // Photoshop's toolbar header chevrons switch between one and two columns. - let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); - // The chevrons point the way the toolbar grows or shrinks: towards the canvas. - let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; - icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); - if resp.clicked() { - app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double; - } - ui.add_space(4.0); + // Toolbar width is a user preference, not a Pro-only feature. + // Keep the same hit target in every theme (including RTL layouts). + let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); + let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; + icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); + if resp.clicked() { + app.ui.panels.toolbar_double = !double; } + ui.add_space(4.0); // Subtle violet wash at the bottom of the toolbar. let full = ui.max_rect(); if !t.bevel && !t.pro && t.dark() { @@ -167,16 +165,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { paint_toolbar_flyout_indicator(ui, &t, &resp); } if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); } // Double-clicking the Hand tool fits the image on screen (Photoshop). if resp.double_clicked() && tool == Tool::Hand { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } if resp.double_clicked() && tool == Tool::Zoom { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); let _ = app.run("view.actualPixels", json!({})); } // Right-click or long-press opens the flyout (Photoshop). @@ -206,7 +204,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if slot.len() > 1 && long_press { // Holding a tool button selects the tool it shows, as a click would (#4453). - app.ui.tool = tool; + crate::tool_memory::select(app, tool); ui.ctx().request_repaint(); } if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) @@ -268,7 +266,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); } if ir.clicked() { - app.ui.tool = item; + crate::tool_memory::select(app, item); ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } @@ -646,9 +644,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("Pixel Art".to_string(), tl!("Pixel Art")), ("Motion".to_string(), tl!("Motion")), ]; - // A narrow bar drops what is also in a menu, Discord first (below), then - // the theme toggle (Preferences), then search (Edit › Search), and narrows - // the switcher (Window › Workspace) before anything runs over. + // A narrow bar drops what is also in a menu, the theme toggle first + // (Preferences), then search (Edit › Search), and narrows the switcher + // (Window › Workspace) before anything runs over. let room = ui.available_width(); let icons_shown: u8 = if room >= WORKSPACE_MIN + 2.0 * ICON_SLOT { 2 @@ -680,25 +678,6 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui)); } - // The community Discord, one click away while the bar has room for it - // (narrow windows drop it first; it is also Help › Discord). - if ui.available_width() >= DISCORD_ROOM { - let discord = egui::Button::image_and_text( - icons::image("message-square", 14.0, t.text_dim), - egui::RichText::new(tl!("Discord")).color(t.text_dim).size(12.0), - ) - .frame(false); - if ui - .add(discord) - .on_hover_text({ - let template = "Join the ArtCraft Discord ({url})"; - crate::i18n::fmt(tl!(template), &[("url", crate::links::DISCORD)]) - }) - .clicked() - { - crate::links::open(app, ui.ctx(), crate::links::DISCORD); - } - } if rtl { ui.min_rect().right() } else { ui.min_rect().left() } }; free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) }; @@ -755,8 +734,6 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Minimum space kept between the window title and the menus or controls beside it. const TITLE_GAP: f32 = 16.0; -/// Free room the title bar needs after its other controls to show the Discord button. -const DISCORD_ROOM: f32 = 120.0; /// The workspace switcher's narrowest width, and one 28 pt title-bar icon with its spacing. const WORKSPACE_MIN: f32 = 90.0; const ICON_SLOT: f32 = 34.0; @@ -1065,7 +1042,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let block = crate::eraser_ui::block_mode(app, app.ui.tool); ui.add_enabled_ui(!block, |ui| { opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); if app.ui.tool == Tool::Eraser { @@ -1316,6 +1293,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::crop_overlay::options_button(o, ui); let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui); widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels")); + // Fill: what a turned or extending crop puts where the image doesn't reach + // (#3823). Generative Expand is not offered. + ui.label(tl!("Fill:")); + widgets::dropdown( + ui, + "crop-fill", + &mut o.crop_content_aware, + &[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))], + 150.0, + ); if crate::crop_ui::preset_ratio(app) != before_ratio { crate::crop_ui::apply_ratio(app); } @@ -1574,8 +1561,8 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { fn opt_label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() }; - let label = ui.label(RichText::new(tl!(&text)).color(t.text_dim)); - widgets::scrub_label(ui, &label); + let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false)); + widgets::scrub_option_label(ui, &label); } fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { @@ -1683,12 +1670,13 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().line_segment([pos2(x, r.top() - 8.0), pos2(x, r.bottom() + 8.0)], Stroke::new(1.0, t.separator)); ui.spacing_mut().item_spacing.y = 4.0; use crate::dock::Group; - let entries: [(&str, &str, Group); 5] = [ + let entries: [(&str, &str, Group); 6] = [ ("sliders-horizontal", tl!("Properties"), Group::Properties), ("navigation", tl!("Navigator"), Group::Navigator), ("palette", tl!("Color & Swatches"), Group::Color), ("layers", tl!("Layers"), Group::Layers), ("clock", tl!("History"), Group::History), + ("brush", tl!("Brushes"), Group::Brushes), ]; for (icon, name, g) in entries { // Studio floats Properties outside the dock. @@ -1733,6 +1721,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan (Group::Navigator, p.navigator), (Group::History, p.history), (Group::Layers, p.layers), + (Group::Brushes, p.brushes), ] .into_iter() .filter_map(|(g, on)| on.then_some(g)) @@ -1774,6 +1763,7 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate (Group::Layers, 0) => layers(app, ui), (Group::Layers, 1) => channels(app, ui), (Group::Layers, _) => crate::vector_ui::paths_panel(app, ui), + (Group::Brushes, _) => crate::brushes_tab::show(app, ui), } } @@ -1847,14 +1837,13 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); // Color Sampler points (#1046): each under its number, with its position in the current ruler // unit and the same RGB/CMYK readout as the pointer (one composite pixel each). - let samplers = app.session.execute("view.colorSamplers.list", json!({})).ok(); - let samplers = samplers.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default(); + let samplers = sampler_readouts(app); if !samplers.is_empty() { widgets::hairline(ui); let cell = |ui: &mut egui::Ui, s: String| { ui.label(RichText::new(s).font(mono.clone()).color(t.text)); }; - for s in &samplers { + for s in samplers.iter() { let n = s["number"].as_i64().unwrap_or(0); let at = s["position"] .as_array() @@ -1877,6 +1866,25 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The Info panel's Color Sampler readouts and the (document, revision) they were composited for. +pub(crate) type SamplerReadouts = ((photocraft_doc::DocId, u64), std::sync::Arc>); + +/// The Color Sampler readouts of the active document, composited once per revision (#4528): +/// adding, moving or deleting a sampler and every pixel edit bump the revision, so between them +/// the Info panel reuses them instead of compositing each point through the stack every frame. +pub(crate) fn sampler_readouts(app: &mut PhotocraftApp) -> std::sync::Arc> { + let Some(key) = app.session.active().map(|s| (s.doc.id, s.revision)) else { return std::sync::Arc::default() }; + if let Some((k, v)) = &app.info_samplers + && *k == key + { + return v.clone(); + } + let list = app.session.execute("view.colorSamplers.list", json!({})).ok(); + let v = std::sync::Arc::new(list.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default()); + app.info_samplers = Some((key, v.clone())); + v +} + fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(idx) = app.session.active_index() else { @@ -1919,7 +1927,7 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // applying egui's smoothed wheel tail a second time. let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; if resp.hovered() - && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms) + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); @@ -2201,31 +2209,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); if t.pro { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); - ui.painter().rect_filled(r, t.radius_sm, t.field); - ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - icons::paint(ui, Rect::from_center_size(r.left_center() + vec2(11.0, 0.0), vec2(14.0, 14.0)), "search", 11.0, t.text_faint); - ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, tl!("Kind"), egui::FontId::proportional(11.5), t.text_dim); - ui.add_space(4.0); - for (kind, icon, tip) in [ - ("pixel", "image", tl!("Filter for pixel layers")), - ("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")), - ("type", "type", tl!("Filter for type layers")), - ("shape", "square", tl!("Filter for shape layers")), - ("smart", "app-window", tl!("Filter for smart objects")), - ] { - let on = app.ui.layer_filter.iter().any(|k| k == kind); - if icons::button(ui, icon, 22.0, on, tip).clicked() { - if on { - app.ui.layer_filter.retain(|k| k != kind); - } else { - app.ui.layer_filter.push(kind.to_string()); - } - } - } - }); + crate::layer_filter_ui::bar(app, ui); ui.add_space(4.0); } if let Some(l) = active_layer { @@ -2350,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.scroll_with_delta(vec2(0.0, delta)); ctx.request_repaint(); } - let filter = app.ui.layer_filter.clone(); + let filter = crate::layer_filter_ui::Filter::of(&app.ui); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -2361,18 +2345,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { continue; } - if !filter.is_empty() { - let kind = match &l.content { - LayerContent::Raster(_) => "pixel", - LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", - LayerContent::Text(_) => "type", - LayerContent::Shape(_) => "shape", - LayerContent::Smart(_) => "smart", - LayerContent::Group(_) => "", - }; - if !filter.iter().any(|k| k == kind) { - continue; - } + if !filter.shows(l) { + continue; } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; let top = ui.cursor().top(); @@ -3059,10 +3033,11 @@ fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, _ => { // Keep row layout and outside thumbnail decorations when the image is clipped. if ui.is_rect_visible(rect) { - // The texture is a letterboxed square: draw only the part the document fills. - let (fitted, uv) = layer_thumb_fit(rect, doc.size.width, doc.size.height); + // The texture is a letterboxed square: draw only the part its frame (the document, + // or the layer's bounds) fills. + let (tex, frame) = app.layer_thumb(ctx, doc, l); + let (fitted, uv) = layer_thumb_fit(rect, frame.width(), frame.height()); widgets::checker(p, fitted, 5.0); - let tex = app.layer_thumb(ctx, doc, l); p.image(tex, fitted, uv, Color32::WHITE); } } @@ -3092,6 +3067,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + let selected_snap = selected_snapshot(ui.ctx(), st); + let ctx = ui.ctx().clone(); + // A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it. + if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(history_drag_key())); + } + let drag = ctx.data(|d| d.get_temp::(history_drag_key())); // Snapshot row (Photoshop shows the document's opening state with a thumbnail). { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::hover()); @@ -3099,7 +3081,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); if let Some(layer) = doc.layers.first() { widgets::checker(ui.painter(), thumb, 4.0); - let tex = app.layer_thumb(&ctx, &doc, layer); + let (tex, _) = app.layer_thumb(&ctx, &doc, layer); ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); @@ -3115,11 +3097,18 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { // Snapshots head the list and scroll with the states, so many of them never push the // footer out of a short dock. - snap_cmd = snapshot_rows(app, ui); + snap_cmd = snapshot_rows(app, ui, selected_snap); for (i, (e, is_redo)) in all.enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, i == current, &e)); - if i == current { + // The current state shows selected unless a snapshot is (Photoshop highlights one row). + let selected = i == current && selected_snap.is_none(); + // Past states can be dragged onto the trash; the oldest one can't be deleted. + let sense = if is_redo || i == 0 { Sense::click() } else { Sense::click_and_drag() }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), sense); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &e)); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i))); + } + if selected { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); @@ -3143,35 +3132,73 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); if resp.clicked() && i != current { target = Some(i); + } else if resp.clicked() && selected_snap.is_some() { + // The current state is already the document: clicking it only selects it. + ctx.data_mut(|d| d.remove::(selected_snapshot_key(doc.id))); } } }); - let mut footer_cmd: Option<&str> = None; + if let Some(drag) = drag + && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) + { + let label = match drag { + HistoryDrag::Snapshot(id) => app.session.active().and_then(|st| st.snapshots.iter().find(|s| s.id == id)).map(|s| s.name.clone()), + HistoryDrag::State(i) => entries.get(i).cloned(), + }; + crate::layer_transfer::ghost(&ctx, p, &label.unwrap_or_default()); + } + let mut footer_cmd: Option<(&str, Value)> = None; if t.pro { - let can_delete = app.session.is_enabled("history.deleteState"); + // With a snapshot selected the trash deletes it, as in Photoshop (#5324). + let can_delete = selected_snap.is_some() || app.session.is_enabled("history.deleteState"); let can_new = app.session.is_enabled("history.newDocument"); widgets::panel_footer(ui, |ui| { - if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner.clicked() { - footer_cmd = Some("history.deleteState"); + let trash = ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner; + if trash.clicked() { + footer_cmd = Some(match selected_snap { + Some(id) => ("history.deleteSnapshot", json!({"snapshot": id})), + None => ("history.deleteState", json!({})), + }); + } + // A snapshot or a past state dragged onto the trash is deleted (#5324). + if let Some(drag) = drag + && dropped_on(ui, &trash) + { + footer_cmd = Some(match drag { + HistoryDrag::Snapshot(id) => ("history.deleteSnapshot", json!({"snapshot": id})), + HistoryDrag::State(i) => ("history.deleteState", json!({"state": i})), + }); } let can_snap = app.session.is_enabled("history.newSnapshot"); if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { - footer_cmd = Some("history.newSnapshot"); + footer_cmd = Some(("history.newSnapshot", json!({}))); } if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() { - footer_cmd = Some("history.newDocument"); + footer_cmd = Some(("history.newDocument", json!({}))); } }); } + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(history_drag_key())); + } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under // its box would leave it transforming pixels that changed. // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); - if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = footer_cmd.filter(|_| app.ui.transform.is_none()).or(snap_cmd) { + let picked = (cmd == "history.selectSnapshot").then(|| p.get("snapshot").and_then(Value::as_u64)).flatten(); + match app.run(cmd, p) { + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + // The snapshot stays selected while the document is the state the click made. + Ok(_) => { + if let (Some(id), Some(st)) = (picked, app.session.active()) { + ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); + } + } + } } // One command per click (#2966): stepping with Undo/Redo once per state made the canvas // recomposite the whole document. @@ -3183,11 +3210,49 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// A History panel row being dragged, for a drop on the trash (#5324). +#[derive(Clone, Copy, Debug)] +enum HistoryDrag { + Snapshot(u64), + State(usize), +} + +fn history_drag_key() -> egui::Id { + egui::Id::new("history-drag") +} + +/// The snapshot the History panel shows selected (#5324), and where the history stood after its +/// click: the number of past states, the current state's name and the number of redo states. +type SelectedSnapshot = (u64, HistorySpot); +type HistorySpot = (usize, Option, usize); + +fn history_spot(st: &photocraft_engine::DocState) -> HistorySpot { + (st.history.past_len(), st.history.entries().pop(), st.history.redo_labels().count()) +} + +fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("history-snapshot-selected", doc)) +} + +/// The selected snapshot: the one last clicked, while it still exists and the history is still at +/// the state that click made. Any other step (an edit, Undo, a state row) deselects it, as in +/// Photoshop. +fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { + let key = selected_snapshot_key(st.doc.id); + let (id, spot) = ctx.data(|d| d.get_temp::(key))?; + let live = spot == history_spot(st) && st.snapshots.iter().any(|s| s.id == id); + if !live { + ctx.data_mut(|d| d.remove::(key)); + } + live.then_some(id) +} + /// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and -/// name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… -/// edits the name in place (Enter or a click elsewhere keeps it, Escape cancels), and -/// right-click › Delete removes it. Returns the command a row asks for. -fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static str, Value)> { +/// name: a click returns to the snapshot (a new state) and selects it, a double-click or +/// right-click › Rename… edits the name in place (Enter or a click elsewhere keeps it, Escape +/// cancels), right-click › Delete removes it, and a drag onto the trash too (#5324). Returns the +/// command a row asks for. +fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option) -> Option<(&'static str, Value)> { let t = Tokens::get(ui.ctx()); let st = app.session.active()?; let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); @@ -3198,9 +3263,15 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static let mut cmd = None; let ctx = ui.ctx().clone(); for snap in &snaps { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &snap.name)); - if resp.hovered() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click_and_drag()); + let is_selected = selected == Some(snap.id); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_selected, &snap.name)); + if resp.drag_started() { + ctx.data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::Snapshot(snap.id))); + } + if is_selected { + ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); + } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); } let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); @@ -4604,7 +4675,7 @@ mod history_transform_tests { mod snapshot_row_tests { use egui::vec2; use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use super::*; @@ -4675,6 +4746,110 @@ mod snapshot_row_tests { assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 2, "deleting leaves the history alone"); } + /// A History panel in the Photoshop theme (the footer with the trash) on red, Snapshot 1, blue. + fn pro_history() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(3); + h + } + + /// The footer's trash: the rightmost of its 26 pt buttons (the bar lays them out right to left). + fn trash(h: &Harness<'static, PhotocraftApp>) -> egui::Pos2 { + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.into_iter().max_by(|a, b| a.center().x.total_cmp(&b.center().x)).unwrap().center() + } + + fn click(h: &mut Harness<'static, PhotocraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + + fn selected(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_all_by_label(label).next().unwrap().accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) + } + + /// Press on `from`, drag in small steps to `to` and release there. + fn drag(h: &mut Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + } + + /// Clicking a snapshot selects it, and the trash then deletes the snapshot instead of a state + /// (#5324). Any other step deselects it, and the trash deletes the current state again. + #[test] + fn the_trash_deletes_the_selected_snapshot() { + let mut h = pro_history(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + let steps = h.state().session.active().unwrap().history.entries().len(); + assert!(selected(&h, "Snapshot 1"), "the clicked snapshot shows selected"); + let p = trash(&h); + click(&mut h, p); + let st = h.state().session.active().unwrap(); + assert!(st.snapshots.is_empty(), "the trash deleted the snapshot"); + assert_eq!(st.history.entries().len(), steps, "and left the states alone"); + + // An edit after the click deselects the snapshot: the trash deletes the state. + let mut h = pro_history(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + h.state_mut().run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + h.run_steps(2); + assert!(!selected(&h, "Snapshot 1"), "an edit deselects the snapshot"); + let steps = h.state().session.active().unwrap().history.entries().len(); + let p = trash(&h); + click(&mut h, p); + let st = h.state().session.active().unwrap(); + assert_eq!(st.snapshots.len(), 1, "the snapshot is kept"); + assert_eq!(st.history.entries().len(), steps - 1, "the current state is deleted"); + } + + /// A snapshot or a past state dragged onto the trash is deleted (#5324). + #[test] + fn rows_dragged_onto_the_trash_are_deleted() { + let mut h = pro_history(); + let steps = h.state().session.active().unwrap().history.entries().len(); + let row = h.get_by_label("Snapshot 1").rect().center(); + let to = trash(&h); + drag(&mut h, egui::pos2(row.x, row.y), to); + let st = h.state().session.active().unwrap(); + assert!(st.snapshots.is_empty(), "the dragged snapshot is deleted"); + assert_eq!(st.history.entries().len(), steps, "no state is"); + + // The second-to-last state: it and the state after it go, as with Delete. + let mut h = pro_history(); + // The states are Open (or New), New Layer, Fill, Fill: drag New Layer. + let entries = h.state().session.active().unwrap().history.entries(); + assert_eq!(entries.len(), 4, "{entries:?}"); + let row = h.get_by_label(&entries[1]).rect().center(); + let to = trash(&h); + drag(&mut h, egui::pos2(row.x, row.y), to); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.entries().len(), 1, "the dragged state and the ones after it are deleted"); + assert_eq!(st.snapshots.len(), 1, "the snapshot is kept"); + } + /// Snapshot rows scroll with the states: many snapshots in a short dock leave the footer and /// its camera button in view. #[test] @@ -5260,35 +5435,45 @@ mod toolbar_tests { use super::*; use egui_kittest::kittest::Queryable; - /// #1197: the header chevron switches the toolbar between one and two columns. + /// #1197 / #3469: every theme can toggle the toolbar without changing its selection. #[test] - fn header_chevron_toggles_two_columns() { - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - toolbar(app, ui); - let left = ui.available_rect_before_wrap().left(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); - } - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); - h.run_steps(2); - let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); - let single = left(&h); - assert!(!h.state().ui.panels.toolbar_double); + fn header_chevron_toggles_two_columns_in_every_layout() { + for theme in [ + crate::theme::ThemeKind::Pro, + crate::theme::ThemeKind::Studio, + crate::theme::ThemeKind::StudioLight, + crate::theme::ThemeKind::Classic, + crate::theme::ThemeKind::AdwaitaDark, + crate::theme::ThemeKind::SolarizedDark, + crate::theme::ThemeKind::BreezeDark, + ] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + let left = ui.available_rect_before_wrap().left(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(2); + let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); + let single = left(&h); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(h.state().ui.panels.toolbar_double); - assert!(left(&h) > single + 20.0, "the toolbar did not widen: {single} -> {}", left(&h)); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(h.state().ui.panels.toolbar_double, "{theme:?}"); + assert!(left(&h) > single + 20.0, "{theme:?}: the toolbar did not widen: {single} -> {}", left(&h)); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(!h.state().ui.panels.toolbar_double); - assert_eq!(left(&h), single); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); + assert_eq!(left(&h), single, "{theme:?}"); + } } fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { @@ -6056,3 +6241,27 @@ mod properties_target_regressions { #[cfg(test)] #[path = "history_panel_select_tests.rs"] mod history_panel_select_tests; + +#[cfg(test)] +mod sampler_readout_tests { + use super::*; + + /// Composited once per revision: repeated frames share the readouts; adding a sampler or an + /// edit recomputes them (#4528). + #[test] + fn sampler_readouts_are_reused_until_the_document_changes() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 4, "y": 4})).unwrap(); + let a = sampler_readouts(&mut app); + assert_eq!(a.len(), 1); + assert_eq!(a[0]["rgb"], json!([255, 255, 255])); + assert!(std::sync::Arc::ptr_eq(&a, &sampler_readouts(&mut app)), "the next frame reuses them"); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let b = sampler_readouts(&mut app); + assert!(!std::sync::Arc::ptr_eq(&a, &b)); + assert_eq!(b[0]["rgb"], json!([255, 0, 0]), "an edit shows"); + app.run("view.colorSamplers.add", json!({"x": 8, "y": 8})).unwrap(); + assert_eq!(sampler_readouts(&mut app).len(), 2, "so does a new sampler"); + } +} diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 8f82e2ad68..b4aaa9e503 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -247,3 +247,87 @@ fn clicking_shape_with_path_selection_selects_its_layer() { crate::vector_ui::path_selection_finish(h.state_mut(), [150.0, 90.0], [150.0, 90.0]); assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } + +/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, +/// etc. This test renders the canvas with a Brush tool active and a work path present, then +/// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. +#[test] +fn path_overlay_drawn_for_non_vector_tools() { + 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(); + // Place a work path whose top edge runs exactly along y=10 in document space. + app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + // Switch to a non-vector tool — before the fix this hid the path outline. + app.ui.tool = Tool::Brush; + + let mut h = Harness::builder().with_size(vec2(900.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; + } + 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(6); + // Fit the view so document coordinates map predictably to screen pixels. + { + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 2.0; // 2 px/doc-px → the 200×200 doc fills 400×400 screen pixels + v.center = [100.0, 100.0]; + v.fit_pending = false; + } + h.run_steps(3); + + // Get the accent colour so we know what to look for. + let accent = crate::theme::Tokens::get(&h.ctx).accent; + + let xf = crate::canvas::ViewXform::active(h.state()).expect("active document view"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().unwrap(); + + // Sample the rendered outline at points derived from the same transform the canvas used. The + // top edge is doc-y=10; x=70..130 stays away from the rectangle's anchor squares. A 1 px + // egui stroke is anti-aliased over the white canvas, so accept visible blends of the accent. + let accent_r = accent.r(); + let accent_g = accent.g(); + let accent_b = accent.b(); + let is_accent_stroke = |pixel: [u8; 4]| { + let denom = f32::from(255u8.saturating_sub(accent_r)).max(1.0); + let coverage = f32::from(255u8.saturating_sub(pixel[0])) / denom; + if !(0.2..=1.05).contains(&coverage) { + return false; + } + let blended = |channel: u8| 255.0 + coverage * (f32::from(channel) - 255.0); + (f32::from(pixel[1]) - blended(accent_g)).abs() <= 12.0 && (f32::from(pixel[2]) - blended(accent_b)).abs() <= 12.0 + }; + + let has_accent_near = |at: egui::Pos2| { + let cx = (at.x * ppp).round() as i32; + let cy = (at.y * ppp).round() as i32; + let radius = (2.0 * ppp).ceil() as i32; + for y in (cy - radius)..=(cy + radius) { + for x in (cx - radius)..=(cx + radius) { + if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() { + let pixel = img.get_pixel(x as u32, y as u32); + if is_accent_stroke([pixel[0], pixel[1], pixel[2], pixel[3]]) { + return true; + } + } + } + } + false + }; + let hits = (70..=130).step_by(5).filter(|x| has_accent_near(xf.to_screen(*x as f32, 10.0))).count(); + assert!( + hits >= 10, + "expected the work path outline to be visible with Brush tool active, \ + but found accent-coloured pixels at only {hits} sample point(s) along the transformed top edge \ + (accent = #{accent_r:02x}{accent_g:02x}{accent_b:02x}); \ + the path must be drawn regardless of the active tool", + ); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index db908ae23f..808a181478 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -52,6 +52,7 @@ pub struct Runtime { show_rulers: Option, /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, + pub(crate) selected_tool: Option, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -128,6 +129,26 @@ pub(crate) fn cycle_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's four interface brightnesses, darkest first. +const BRIGHTNESSES: [ThemeKind; 4] = [ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::ProLightGray, ThemeKind::ProLight]; + +/// Darken Interface (⇧⌘F1, `lighter` false) and Lighten Interface (⇧⌘F2): the next of +/// Photoshop's four brightnesses from the theme on screen, stopping at either end, as in +/// Photoshop. Another theme steps from the nearest brightness: Medium Gray for a dark one, Light +/// Gray for a light one. The choice is saved like picking it in Preferences › Interface. +pub(crate) fn step_brightness(app: &mut PhotocraftApp, ctx: &egui::Context, lighter: bool) -> Result<(), String> { + let current = app.ui.theme; + let at = BRIGHTNESSES.iter().position(|k| *k == current).unwrap_or(if crate::theme::Tokens::for_kind(current).dark() { 1 } else { 2 }); + let next = if lighter { (at + 1).min(BRIGHTNESSES.len() - 1) } else { at.saturating_sub(1) }; + let Some(&kind) = BRIGHTNESSES.get(next) else { return Ok(()) }; + if kind == current { + return Ok(()); + } + app.run("prefs.set", json!({"path": "interface.theme", "value": kind.id()}))?; + sync_appearance(app, ctx); + Ok(()) +} + const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("proLight", ThemeKind::ProLight), ("proLightGray", ThemeKind::ProLightGray), @@ -984,6 +1005,7 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { "tab": tab, "filter": "", "overrides": p.shortcuts, + "alternates": p.alternate_shortcuts, "hidden": p.menus.hidden, "colors": p.menus.colors, "toolbarHidden": p.toolbar.hidden, @@ -997,8 +1019,13 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { /// Open the Embedded Profile Mismatch prompt for a file waiting to open (`open_prompt`): its /// `message`, the answer selected first (`action`), and `marker`, the field that routes OK and /// Cancel back to `open_prompt`. -pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str) -> u64 { - let mut fields = json!({"message": message, "action": action, "missing": missing}); +/// `names` carries the embedded and working profile names and the Color Settings policy a +/// remembered choice is saved to (`policyKey`, null for a mode without one). +pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str, names: Value) -> u64 { + let mut fields = json!({"message": message, "action": action, "missing": missing, "remember": false}); + if let (Some(f), Value::Object(n)) = (fields.as_object_mut(), names) { + f.extend(n); + } fields[marker] = json!(true); open_kind(app, "mismatch", tl!("Embedded Profile Mismatch"), fields) } @@ -1024,6 +1051,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map String { match key { "appearanceMode" => return "Appearance Mode".into(), + "exrAcesCg" => return "Open color-less EXR as ACEScg".into(), "darkTheme" => return "Dark Theme".into(), "lightTheme" => return "Light Theme".into(), _ => {} @@ -1042,6 +1070,13 @@ fn humanize(key: &str) -> String { if key == "notificationDurationSeconds" { return "Notification Duration (seconds)".into(); } + // Tools: arrow-key nudges bundle into one History state (#2259). + if key == "bundleNudges" { + return "Bundle Arrow-Key Nudges".into(); + } + if key == "nudgeBundlePauseMs" { + return "Nudge Bundle Pause (ms)".into(); + } let mut s = String::new(); for (i, ch) in key.chars().enumerate() { if i == 0 { @@ -1289,6 +1324,11 @@ fn export_field_visible(obj: &Map, key: &str) -> bool { } } +/// The pause that bundles arrow-key nudges means nothing with Bundle Arrow-Key Nudges off (#2259). +fn tools_field_visible(obj: &Map, key: &str) -> bool { + key != "nudgeBundlePauseMs" || obj.get("bundleNudges").and_then(Value::as_bool) != Some(false) +} + /// One theme as a Color Theme swatch: its panel colour under a chrome strip with an accent dot, /// outlined when chosen. The name is the tooltip and the accessible label. A star badge on its /// corner adds the theme to (or removes it from) the favourites listed by the appearance @@ -1473,7 +1513,8 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) - || (section == "export" && !export_field_visible(obj, k)); + || (section == "export" && !export_field_visible(obj, k)) + || (section == "tools" && !tools_field_visible(obj, k)); (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) @@ -1645,6 +1686,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v.clone())); let overrides_ok = parsed.as_ref().is_none_or(Result::is_ok); let mut overrides = parsed.and_then(Result::ok).unwrap_or_default(); + // A command's further keys (#3665): id → keys, replacing its default alternates. + let parsed_alts = f.get("alternates").map(|v| serde_json::from_value::>>(v.clone())); + let alternates_ok = parsed_alts.as_ref().is_none_or(Result::is_ok); + let mut alt_overrides = parsed_alts.and_then(Result::ok).unwrap_or_default(); + let mut capture_alt = f.get("captureAlt").and_then(Value::as_bool).unwrap_or(false); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1664,7 +1710,15 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map def.clone(), } }; - // Key capture for the selected command. + // A command's further keys as the dialog stands: its edited list, else its defaults. + let eff_alts = |alts: &BTreeMap>, id: &str, main: Option<&str>| -> Vec { + let mut p = prefs::Preferences::default(); + if let Some(list) = alts.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + p.alternates(id, main) + }; + // Key capture for the selected command: its main key, or (Add Shortcut) one more key. if capture && tab == 0 && !selected.is_empty() { let pressed = ui.input(|i| { i.events.iter().find_map(|e| match e { @@ -1679,20 +1733,39 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items .iter() - .filter(|(id, _, _, def)| *id != selected && eff(&overrides, id, def).as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())) + .filter(|(id, _, _, def)| { + let cur = eff(&overrides, id, def); + *id != selected + && (cur.as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone()) + || eff_alts(&alt_overrides, id, cur.as_deref()).contains(&sc)) + }) .map(|(id, label, path, _)| { let (path, label) = shown(id, label, path); format!("{} › {}", path.join(" › "), label.trim_end_matches('…')) }) .collect(); - overrides.insert(selected.clone(), sc.clone()); + if capture_alt { + let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone()); + let cur = eff(&overrides, &selected, &def); + let mut keys = eff_alts(&alt_overrides, &selected, cur.as_deref()); + if cur.as_deref().and_then(prefs::normalize_shortcut) != Some(sc.clone()) && !keys.contains(&sc) && keys.len() < prefs::MAX_ALTERNATES { + keys.push(sc.clone()); + } + alt_overrides.insert(selected.clone(), keys); + } else { + overrides.insert(selected.clone(), sc.clone()); + } message_warning = !clash.is_empty(); message = if clash.is_empty() { let template = "{shortcut} assigned."; @@ -1714,13 +1787,16 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { ui.add_space(6.0); let mut action = f.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); let missing = f.get("missing").and_then(Value::as_bool).unwrap_or(false); - let opts: &[(&str, &str)] = if missing { - &[("preserve", tl!("Leave as is (don't color manage)")), ("assignWorking", tl!("Assign working space"))] + let name = |k: &str| f.get(k).and_then(Value::as_str).unwrap_or("?").to_string(); + let opts: Vec<(&str, String)> = if missing { + vec![("preserve", tl!("Leave as is (don't color manage)").into()), ("assignWorking", tl!("Assign working space").into())] } else { - &[ - ("preserve", tl!("Use the embedded profile (instead of the working space)")), - ("convert", tl!("Convert document's colors to the working space")), - ("discard", tl!("Discard the embedded profile (don't color manage)")), + vec![ + ("preserve", crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))])), + ("convert", crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))])), + ("discard", tl!("Discard the embedded profile (don't color manage)").into()), ] }; for (v, label) in opts { - ui.radio_value(&mut action, v.to_string(), *label); + ui.radio_value(&mut action, v.to_string(), label); } f.insert("action".into(), json!(action)); + // Remembering sets the Color Settings policy and stops asking, so it needs a mode with a policy. + if !missing && f.get("policyKey").is_some_and(Value::is_string) { + ui.add_space(8.0); + let mut remember = f.get("remember").and_then(Value::as_bool).unwrap_or(false); + crate::widgets::checkbox(ui, &mut remember, tl!("Remember this choice for future images")) + .on_hover_text(tl!("Turn the prompt back on with Ask On Mismatch in Edit › Color Settings.")); + f.insert("remember".into(), json!(remember)); + } } // ------------------------------------------------------------------ confirm @@ -1949,33 +2071,67 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result = serde_json::from_value(overrides).map_err(|e| format!("overrides must map command IDs to shortcut strings: {e}"))?; + let alternates = f.get("alternates").cloned().unwrap_or(json!({})); + let mut alt: BTreeMap> = + serde_json::from_value(alternates).map_err(|e| format!("alternates must map command IDs to lists of shortcuts: {e}"))?; let items = shortcut_items(app); // Newly assigned shortcuts are checked first and take theirs from any holder, custom - // overrides included; unchanged overrides only take theirs from defaults. + // overrides included; unchanged overrides only take theirs from defaults. A + // command's further keys (#3665) take and lose keys the same way. let before = &app.session.prefs().shortcuts; - let mut todo: Vec<(String, String, bool)> = - ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s))).collect(); - todo.sort_by_key(|(_, _, new)| !new); - for (id, sc, new) in todo { - if ov.get(&id).is_some_and(String::is_empty) { - continue; // taken by a newer assignment + let before_alt = &app.session.prefs().alternate_shortcuts; + let mut todo: Vec<(String, String, bool, bool)> = + ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s), false)).collect(); + for (k, keys) in &alt { + let new = before_alt.get(k) != Some(keys); + todo.extend(keys.iter().map(|s| (k.clone(), s.clone(), new, true))); + } + todo.sort_by_key(|(_, _, new, _)| !new); + let main_of = |ov: &BTreeMap, id: &str, def: Option<&str>| -> Option { + match ov.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.clone()), + None => def.map(str::to_string), } + }; + for (id, sc, new, is_alt) in todo { let norm = prefs::normalize_shortcut(&sc); + let Some(norm) = norm else { continue }; + // Taken by a newer assignment? + if (!is_alt && ov.get(&id).is_some_and(String::is_empty)) || (is_alt && alt.get(&id).is_some_and(|k| !k.contains(&norm))) { + continue; + } for (other, _, _, def) in &items { + if *other == id { + continue; + } let held = match ov.get(other) { Some(s) if new => Some(s.as_str()), Some(_) => None, None => def.as_deref(), }; - if *other != id && held.and_then(prefs::normalize_shortcut) == norm { + if held.and_then(prefs::normalize_shortcut).as_deref() == Some(norm.as_str()) { ov.insert(other.clone(), String::new()); } + // Its further keys: the edited list when this assignment is new, else only + // the defaults. + if alt.contains_key(other) && !new { + continue; + } + let mut p = prefs::Preferences::default(); + if let Some(list) = alt.get(other) { + p.alternate_shortcuts.insert(other.clone(), list.clone()); + } + let keys = p.alternates(other, main_of(&ov, other, def.as_deref()).as_deref()); + if keys.contains(&norm) { + alt.insert(other.clone(), keys.into_iter().filter(|k| *k != norm).collect()); + } } } let hidden = f.get("hidden").cloned().unwrap_or(json!([])); let colors = f.get("colors").cloned().unwrap_or(json!({})); let toolbar = f.get("toolbarHidden").cloned().unwrap_or(json!([])); - app.run("edit.keyboardShortcuts", json!({"reset": true, "set": ov, "allowUnknown": true, "removeConflicts": false}))?; + app.run("edit.keyboardShortcuts", json!({"reset": true, "set": ov, "alternates": alt, "allowUnknown": true, "removeConflicts": false}))?; app.run("prefs.set", json!({"values": {"menus": {"hidden": hidden, "colors": colors}, "toolbar": {"hidden": toolbar}}})) } "presets" => Ok(Value::Null), @@ -2001,6 +2157,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let action = f.get("action").and_then(Value::as_str).unwrap_or("preserve"); + if f.get("remember").and_then(Value::as_bool) == Some(true) { + return crate::open_prompt::answer_remembering(app, action); + } crate::open_prompt::answer(app, Some(action)) } _ => Err("unknown dialog".into()), @@ -2067,6 +2226,21 @@ mod tests { assert_eq!(humanize("webpQuality"), "Quality"); } + #[test] + fn the_nudge_pause_is_hidden_while_nudges_are_not_bundled() { + let mut tools = Map::new(); + tools.insert("bundleNudges".into(), json!(true)); + assert!(tools_field_visible(&tools, "nudgeBundlePauseMs")); + assert!(tools_field_visible(&tools, "bundleNudges")); + tools.insert("bundleNudges".into(), json!(false)); + assert!(!tools_field_visible(&tools, "nudgeBundlePauseMs")); + assert!(tools_field_visible(&tools, "bundleNudges") && tools_field_visible(&tools, "showTooltips")); + // A section without the flag (an older preferences object) shows the field. + assert!(tools_field_visible(&Map::new(), "nudgeBundlePauseMs")); + assert_eq!(humanize("bundleNudges"), "Bundle Arrow-Key Nudges"); + assert_eq!(humanize("nudgeBundlePauseMs"), "Nudge Bundle Pause (ms)"); + } + #[test] fn rendering_mode_display_respects_explicit_mode_and_legacy_disable() { let legacy = serde_json::json!({"useGpu": false, "gpuBackend": "auto"}); @@ -2166,6 +2340,65 @@ mod tests { } } + #[test] + fn darken_and_lighten_interface_step_through_photoshops_brightnesses_and_stop_at_the_ends() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + sync_appearance(&mut app, &ctx); + assert_eq!(app.ui.theme, ThemeKind::ProMedium, "Photoshop's default brightness"); + let press = |app: &mut PhotocraftApp, id: &str| crate::menus::invoke(app, &ctx, id, json!({})).unwrap(); + for expected in [ThemeKind::ProLightGray, ThemeKind::ProLight, ThemeKind::ProLight] { + press(&mut app, "view.lightenInterface"); + assert_eq!(app.ui.theme, expected); + } + // Saved like a choice in Preferences › Interface: the light slot and the Light appearance. + let i = &app.session.prefs().interface; + assert_eq!((i.appearance_mode, i.light_theme), (AppearanceMode::Light, LightTheme::ProLight)); + for expected in [ThemeKind::ProLightGray, ThemeKind::ProMedium, ThemeKind::Pro, ThemeKind::Pro] { + press(&mut app, "view.darkenInterface"); + assert_eq!(app.ui.theme, expected); + } + let i = &app.session.prefs().interface; + assert_eq!((i.appearance_mode, i.dark_theme), (AppearanceMode::Dark, DarkTheme::Pro)); + // Another theme steps from the nearest brightness. + for (theme, id, expected) in [ + ("studio", "view.darkenInterface", ThemeKind::Pro), + ("studio", "view.lightenInterface", ThemeKind::ProLightGray), + ("classic", "view.darkenInterface", ThemeKind::ProMedium), + ("classic", "view.lightenInterface", ThemeKind::ProLight), + ] { + app.run("prefs.set", json!({"path": "interface.theme", "value": theme})).unwrap(); + sync_appearance(&mut app, &ctx); + press(&mut app, id); + assert_eq!(app.ui.theme, expected, "{theme} {id}"); + } + } + + #[test] + fn ctrl_shift_f1_and_f2_darken_and_lighten_the_interface() { + for (id, shortcut) in [("view.darkenInterface", "Cmd+Shift+F1"), ("view.lightenInterface", "Cmd+Shift+F2")] { + assert_eq!(crate::shortcuts::default_shortcut(id).as_deref(), Some(shortcut), "{id}"); + } + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + sync_appearance(&mut app, &ctx); + let press = |app: &mut PhotocraftApp, key| { + let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + 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(); + }; + press(&mut app, egui::Key::F1); + assert_eq!(app.ui.theme, ThemeKind::Pro); + press(&mut app, egui::Key::F2); + press(&mut app, egui::Key::F2); + assert_eq!(app.ui.theme, ThemeKind::ProLightGray); + } + fn appearance_frame(app: &mut PhotocraftApp, ctx: &egui::Context, appearance: Option) { ctx.run_ui(egui::RawInput { system_theme: appearance, ..Default::default() }, |ui| tick(app, ui.ctx())).textures_delta.clear(); } @@ -2729,6 +2962,110 @@ mod tests { assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip); } + #[test] + fn selected_tool_survives_a_restart_without_option_changes() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Brush); + // Every tool, including flyout-only tools, can be restored. Return to Brush last to + // prove selecting the default overwrites a previously remembered nondefault tool. + for tool in Tool::ALL.into_iter().chain([Tool::Brush]) { + app.ui.tool = tool; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], serde_json::to_value(tool).unwrap()); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, tool, "restored before the first frame"); + assert_eq!(app2.ui.group_tool(tool.key()), Some(tool)); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); + } + } + + #[test] + fn selected_tool_is_flushed_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.ui.tool = crate::state::Tool::Move; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, crate::state::Tool::Move); + } + + #[test] + fn selected_tool_explicit_eraser_survives_exit_during_temporary_switches() { + use crate::state::Tool; + for spring in [false, true] { + for menu in [false, true] { + let (mut app, store) = app_with_store(); + app.stylus.tool_before_eraser = Some(Tool::Zoom); + app.ui.tool = Tool::Eraser; + if spring { + crate::spring_tools::pressed(&mut app, 'E', Tool::Zoom, true, 0.0); + } + if menu { + crate::menus::invoke(&mut app, &egui::Context::default(), "tools.select.eraser", json!({})).unwrap(); + } else { + crate::tool_memory::select(&mut app, Tool::Eraser); + } + assert_eq!(app.stylus.tool_before_eraser, None); + assert!(app.ui.spring.is_none()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.ui.tool, Tool::Eraser); + } + } + } + + #[test] + fn invalid_selected_tool_preserves_other_preferences() { + for bad in [json!(null), json!("FutureTool"), json!(42), json!({}), json!([])] { + let (app, _) = app_with_saved(Some( + json!({ + "selectedTool": bad, "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.ui.tool, crate::state::Tool::Brush, "{bad}"); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + } + + #[test] + fn selected_tool_excludes_temporary_switches() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::Move; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + app.tool_override = Some(Tool::Hand); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Move")); + app.tool_override = None; + app.ui.tool = Tool::Zoom; + crate::spring_tools::pressed(&mut app, 'Z', Tool::Move, true, 0.0); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Move")); + // A quick tap commits the choice after spring_tools clears the pending switch. + crate::spring_tools::tick(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Zoom")); + app.stylus.tool_before_eraser = Some(Tool::Zoom); + app.ui.tool = Tool::Eraser; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Zoom")); + // An explicit selection during a temporary switch takes precedence. + app.ui.tool = Tool::PolygonLasso; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("PolygonLasso")); + } + #[test] fn tool_options_survive_a_restart() { let (mut app, store) = app_with_store(); @@ -3147,6 +3484,52 @@ mod tests { assert_eq!(effective_shortcut(&app, "file.open", Some("Cmd+O")), None, "File › Open stays cleared"); } + /// #3665: the dialog keeps a command's further keys: they move like main keys, an override + /// of the main key leaves them, and menus show only the main key. + #[test] + fn shortcut_dialog_moves_and_keeps_alternate_keys() { + let (mut app, _) = app_with_store(); + let ctx = egui::Context::default(); + let open = |app: &mut PhotocraftApp| crate::menus::invoke(app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert_eq!(crate::shortcuts::effective_alternates(&app, "view.zoomIn"), ["Cmd++"]); + // F2 (Cut's alternate) given to Rename Layer is taken from Cut, which keeps ⌘X. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"layer.renameLayer": "F2"})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(effective_shortcut(&app, "layer.renameLayer", None).as_deref(), Some("F2")); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(effective_shortcut(&app, "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+X")); + // Copy's main key moved to ⌘K keeps F3; the menu shows ⌘K. + let id = open(&mut app); + let mut ov = app.session.prefs().shortcuts.clone(); + ov.insert("edit.copy".into(), "Cmd+K".into()); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!(ov)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::menus::menu_items(&app).iter().any(|i| i.id == "edit.copy" && i.shortcut.as_deref() == Some("Cmd+K"))); + // An added alternate takes its key from the main key that held it. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["F4", "Cmd+K"]})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"]); + assert_eq!(effective_shortcut(&app, "edit.copy", Some("Cmd+C")), None, "taken from Copy"); + // Use Default for everything restores the defaults; a malformed list is an error. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": "F4"})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["Cmd+Shift"]})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"], "nothing changed"); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({})); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.prefs().alternate_shortcuts.is_empty() && app.session.prefs().shortcuts.is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.cut"), ["F2"]); + } + #[test] fn shortcut_dialog_rejects_malformed_overrides() { // #956: a non-map `overrides` was read as "no overrides" and wiped every custom shortcut. @@ -3183,6 +3566,16 @@ mod tests { assert!(!crate::menus::menu_items(&app).iter().any(|i| i.id == "edit.fade")); } + /// #5096: preferences that list Edit › Menus or File › Exit as hidden still show them. + #[test] + fn essential_menu_items_stay_visible() { + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"menus": {"hidden": ["edit.menus", "file.exit", "edit.fade"], "colors": {}}}})).unwrap(); + let items = crate::menus::menu_items(&app); + assert!(items.iter().any(|i| i.id == "edit.menus") && items.iter().any(|i| i.id == "file.exit")); + assert!(!items.iter().any(|i| i.id == "edit.fade")); + } + #[test] fn autosave_runs_for_dirty_documents() { type Saved = Arc>>; diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 6138c08844..1ea0725838 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -4,7 +4,8 @@ //! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through //! `swatches.import`, patterns (`.pat`) to the Patterns panel through `pattern.import`, keyboard //! shortcut sets (`.kys`) to Edit › Keyboard Shortcuts through -//! [`crate::kys_import`], instead of opening as documents. +//! [`crate::kys_import`], instead of opening as documents. Import Brushes… also takes an image +//! file, which becomes a brush tip through `brush.presets.importImage` (TOOL-213-12). use serde_json::{Value, json}; @@ -25,6 +26,36 @@ pub fn is_preset_file(name: &str) -> bool { PRESET_EXTS.contains(&ext(name).as_str()) } +/// Image files Import Brushes… offers besides `.abr`: each becomes a sampled brush tip. +pub const BRUSH_IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "psd", "psb", "bmp", "gif", "webp", "tga"]; + +/// Brushes panel / brush preset picker › Import Brushes…: an `.abr` joins the brush library as a +/// group (as File › Open does), an image file becomes a new brush preset with that image as its tip. +pub fn import_brushes(app: &mut PhotocraftApp) -> Result { + let exts: Vec<&str> = std::iter::once("abr").chain(BRUSH_IMAGE_EXTS.iter().copied()).collect(); + app.pick_file_bytes_filtered(&exts, |app, name, bytes| match open(app, &name, &bytes) { + Some(r) => r.map(|()| Value::Null), + None => import_brush_image(app, &name, &bytes), + }) +} + +/// An image file as a brush tip: a new preset named after the file, made the current brush. +pub fn import_brush_image(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { + let file = std::path::Path::new(name).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); + let mut p = source(name, bytes); + if p.get("data").is_some() { + p["fileName"] = json!(file); + } + let r = app.run("brush.presets.importImage", p)?; + let preset = r.get("name").and_then(Value::as_str).unwrap_or_default(); + app.ui.status_error = false; + app.ui.status = format!("Imported brush {preset} from {file}"); + // Show it: Brush Settings › Brushes tab. + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + Ok(r) +} + /// Import from a gradient picker through the same preset command as File › Open. pub(crate) fn import_gradients_dialog(app: &mut PhotocraftApp) { let result = app.pick_file_bytes_filtered(&["grd"], |app, name, bytes| { @@ -114,4 +145,33 @@ mod tests { assert!(!is_preset_file("photo.psd")); assert!(!is_preset_file("abr")); } + + /// Import Brushes… with a PNG: the image becomes the current brush's tip (TOOL-213-12). + #[test] + fn import_brushes_turns_an_image_into_a_brush_tip() { + use photocraft_engine::paint::TipShape; + let mut px = vec![255u8; 16 * 16]; + for y in 4..12 { + for x in 6..9 { + px[y * 16 + x] = 0; + } + } + let img = photocraft_codecs::Image::from_u8(16, 16, photocraft_codecs::ChannelLayout::Gray, px).unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let (show, asked) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Contents("Leaf.png".into(), png))]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + import_brushes(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + match asked.borrow().first() { + Some(crate::file_dialog::FileDialogRequest::Open { extensions: Some(e), .. }) => { + assert!(e.iter().any(|x| x == "abr") && e.iter().any(|x| x == "png"), "{e:?}") + } + other => panic!("expected a filtered open dialog, got {other:?}"), + } + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(app.session.tools.current_preset.as_deref(), Some("Leaf")); + let TipShape::Sampled(tip) = &app.session.tools.brush.tip else { panic!("sampled tip expected") }; + assert_eq!((tip.width, tip.height), (3, 8)); + assert!(app.ui.panels.brush_settings && app.ui.brush_tab == 1, "the Brushes tab shows the new brush"); + } } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 7e9e9b6727..8411c5af63 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -334,12 +334,20 @@ const FOOTER_GUESS: f32 = 32.0; /// Draws the folders and footer; `thumb` paints item `(group, item)` into a rect. /// Where a browser sits: the canvas (drop target), its height cap and the New button's tooltip. +/// `flush` lays the grid out as Photoshop's Swatches panel: the cells touch, square-cornered and +/// without a border of their own, so no screen is spent on gaps (#5103); gradients, patterns and +/// the other preset grids keep their [`GRID_GAP`] and card borders. pub(crate) struct Place<'a> { pub(crate) canvas: Rect, pub(crate) max_h: f32, pub(crate) new_tip: &'a str, + pub(crate) flush: bool, } +/// Space between the cells of a preset grid, and the grid's inset from the panel's left edge. +const GRID_GAP: f32 = 4.0; +const GRID_INSET: f32 = 8.0; + pub(crate) fn browser( ui: &mut egui::Ui, st: &mut PresetUi, @@ -349,7 +357,7 @@ pub(crate) fn browser( thumb: &mut dyn FnMut(&egui::Ui, Rect, usize, usize), ) -> Vec { let t = Tokens::get(ui.ctx()); - let Place { canvas, max_h, new_tip } = place; + let Place { canvas, max_h, new_tip, flush } = place; let mut ev = Vec::new(); let size = *st.sizes.get(panel).unwrap_or(&40.0); let list = size <= MIN_THUMB + 0.5; @@ -439,15 +447,15 @@ pub(crate) fn browser( continue; } let w = ui.available_width(); + let pad = if list { 0.0 } else { GRID_INSET }; + let gap = if list || flush { 0.0 } else { GRID_GAP }; let (cell, cols) = if list { (vec2(w, 24.0), 1) } else { - let cols = ((w - 8.0 + 4.0) / (size + 4.0)).floor().max(1.0) as usize; + let cols = ((w - pad + gap) / (size + gap)).floor().max(1.0) as usize; (vec2(size, size), cols) }; let rows = g.items.len().div_ceil(cols); - let pad = if list { 0.0 } else { 8.0 }; - let gap = if list { 0.0 } else { 4.0 }; let (area, _) = ui.allocate_exact_size(vec2(w, rows as f32 * (cell.y + gap) + 2.0), Sense::hover()); for (ii, it) in g.items.iter().enumerate() { let (cx, cy) = ((ii % cols) as f32, (ii / cols) as f32); @@ -466,14 +474,20 @@ pub(crate) fn browser( ui.painter().text(pos2(tr.right() + 8.0, r.center().y), Align2::LEFT_CENTER, &it.name, FontId::proportional(11.5), t.text); } else { thumb(ui, r, gi, ii); - let (stroke, kind) = if is_sel { - (Stroke::new(2.0, t.accent), egui::StrokeKind::Outside) + // Touching cells draw no border of their own: only the selected and the + // hovered one are outlined, over their neighbours, as in Photoshop. + let stroke = if is_sel { + Some((Stroke::new(2.0, t.accent), egui::StrokeKind::Outside)) } else if resp.hovered() { - (Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside) + Some((Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside)) + } else if flush { + None } else { - (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) + Some((Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside)) }; - ui.painter().rect_stroke(r, CornerRadius::same(2), stroke, kind); + if let Some((stroke, kind)) = stroke { + ui.painter().rect_stroke(r, CornerRadius::same(if flush { 0 } else { 2 }), stroke, kind); + } } let resp = if list { resp } else { resp.on_hover_text(&it.name) }; if resp.double_clicked() { @@ -667,10 +681,11 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 260.0); - let events = browser(ui, &mut st, "gradients", &groups, Place { canvas, max_h, new_tip: tl!("Create new gradient") }, &mut |ui, r, gi, ii| { - let stops = groups_src[gi].items[ii].resolve(fg, bg); - paint_gradient(ui, r, &stops); - }); + let events = + browser(ui, &mut st, "gradients", &groups, Place { canvas, max_h, new_tip: tl!("Create new gradient"), flush: false }, &mut |ui, r, gi, ii| { + let stops = groups_src[gi].items[ii].resolve(fg, bg); + paint_gradient(ui, r, &stops); + }); app.ui.presets_ui = st; for e in events { match &e { @@ -715,11 +730,17 @@ pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 280.0); let ctx = ui.ctx().clone(); - let events = - browser(ui, &mut st, "patterns", &groups, Place { canvas, max_h, new_tip: tl!("Create new pattern from the selection") }, &mut |ui, r, gi, ii| { + let events = browser( + ui, + &mut st, + "patterns", + &groups, + Place { canvas, max_h, new_tip: tl!("Create new pattern from the selection"), flush: false }, + &mut |ui, r, gi, ii| { let tex = pattern_texture(&ctx, &pats[gi][ii]); ui.painter().image(tex.id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - }); + }, + ); app.ui.presets_ui = st; for e in events { match &e { @@ -820,7 +841,7 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &mut st, "styles", &groups, - Place { canvas, max_h: 300.0, new_tip: tl!("Create new style from the selected layer") }, + Place { canvas, max_h: 300.0, new_tip: tl!("Create new style from the selected layer"), flush: false }, &mut |ui, r, gi, ii| { ui.painter().rect_filled(r, 2.0, t.field); ui.painter().image(texes[gi][ii].id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); @@ -864,7 +885,7 @@ pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &mut st, "shapes", &groups, - Place { canvas, max_h: 300.0, new_tip: tl!("Create new shape from the current path") }, + Place { canvas, max_h: 300.0, new_tip: tl!("Create new shape from the current path"), flush: false }, &mut |ui, r, gi, ii| { let tex = shape_texture(&ctx, &src[gi].items[ii]); ui.painter().image(tex.id(), r.shrink(2.0), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); @@ -983,7 +1004,7 @@ fn tool_id(t: Tool) -> String { pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return }; if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); // Each tool keeps its own brush (#218), so switch it in before the preset's brush lands // on it, and re-apply the brush the command just set for the old tool. crate::paint_mouse::sync_tool_brush(app); diff --git a/crates/ui-egui/src/pressure_curve_ui.rs b/crates/ui-egui/src/pressure_curve_ui.rs index cb9880733f..0c8c5b7c4d 100644 --- a/crates/ui-egui/src/pressure_curve_ui.rs +++ b/crates/ui-egui/src/pressure_curve_ui.rs @@ -1,5 +1,6 @@ //! Preferences › Tools › Pressure Curve: the pen's pressure response, edited as points on a curve //! (`tools.pressureCurve`, read by the stylus through `photocraft_engine::prefs::PressureCurve`). +//! Brush Settings uses the same editor for each dynamic's own curve ([`edit_points`]). //! Drag a point to move it, click empty space to add one, right-click a point to remove it. use egui::{Pos2, Sense, Stroke, Vec2, pos2}; @@ -46,17 +47,25 @@ fn round2(x: f32) -> f64 { /// The editor. Returns the new value when the curve changed. pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { - let t = Tokens::get(ui.ctx()); let mut points = points_of(value); + let changed = edit_points(ui, egui::Id::new(RECT_KEY), &mut points); + changed.then(|| value_of(&points)) +} + +/// The editor over a list of points (fewer than two reads as linear). `rect_key` is the egui +/// memory key the editing square's rectangle is stored under, for tests and automation. Returns +/// whether the points changed; they are rounded to two decimals then. +pub(crate) fn edit_points(ui: &mut egui::Ui, rect_key: egui::Id, points: &mut Vec<[f32; 2]>) -> bool { + let t = Tokens::get(ui.ctx()); if points.len() < 2 { - points = vec![[0.0, 0.0], [1.0, 1.0]]; + *points = vec![[0.0, 0.0], [1.0, 1.0]]; } let mut changed = false; // The square with the presets beside it, so the control fits the preferences page's height. ui.horizontal_top(|ui| { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click_and_drag()); // Where the square is this frame (tests and automation point at curve coordinates). - ui.data_mut(|d| d.insert_temp(egui::Id::new(RECT_KEY), rect)); + ui.data_mut(|d| d.insert_temp(rect_key, rect)); let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.height()); let to_curve = |s: Pos2| [((s.x - rect.left()) / rect.width()).clamp(0.0, 1.0), ((rect.bottom() - s.y) / rect.height()).clamp(0.0, 1.0)]; let painter = ui.painter_at(rect.expand(4.0)); @@ -80,7 +89,7 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { if resp.drag_started() && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { - let grabbed = nearest(at, &points); + let grabbed = nearest(at, points); ui.data_mut(|d| d.insert_temp(drag_id, grabbed)); } let dragging: Option = ui.data(|d| d.get_temp(drag_id)).flatten(); @@ -95,7 +104,7 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { } if resp.clicked() && let Some(at) = resp.interact_pointer_pos() - && nearest(at, &points).is_none() + && nearest(at, points).is_none() && points.len() < PressureCurve::MAX_POINTS { points.push(to_curve(at)); @@ -103,14 +112,14 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { } if resp.secondary_clicked() && let Some(at) = resp.interact_pointer_pos() - && let Some(i) = nearest(at, &points) + && let Some(i) = nearest(at, points) && points.len() > 2 { points.remove(i); changed = true; } - let curve = PressureCurve::new(&points); + let curve = PressureCurve::new(points); let line: Vec = (0..=64).map(|i| i as f32 / 64.0).map(|x| to_screen([x, curve.eval(x)])).collect(); painter.add(egui::Shape::line(line, Stroke::new(2.0, t.accent))); for (i, p) in points.iter().enumerate() { @@ -122,13 +131,19 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { ui.vertical(|ui| { for (name, preset) in PRESETS { if ui.small_button(tl!(name)).clicked() { - points = preset.to_vec(); + *points = preset.to_vec(); changed = true; } } }); }); - changed.then(|| value_of(&points)) + if changed { + // Two decimals: the stored value stays readable and a drag doesn't write float noise. + for p in points.iter_mut() { + *p = [round2(p[0]) as f32, round2(p[1]) as f32]; + } + } + changed } #[cfg(test)] diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 6828da6f70..2ede8ffef0 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -13,8 +13,8 @@ use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; use serde_json::Value; -const USE: &str = "Use the embedded profile (instead of the working space)"; -const CONVERT: &str = "Convert document's colors to the working space"; +const USE: &str = "Use the embedded profile (Adobe RGB (1998)-compatible (Photocraft))"; +const CONVERT: &str = "Convert to the working space (sRGB IEC61966-2.1)"; const DISCARD: &str = "Discard the embedded profile (don't color manage)"; /// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. @@ -135,35 +135,36 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { } } -/// The prompt reads in the interface language: its sentence, the Embedded and Working lines and -/// the mode's name (French "RVB"). The file's name goes in as it is, braces and all. +/// The prompt reads in the interface language: its sentence, the mode's name (French "RVB") and +/// the choices, which name the embedded and working profiles. The file's name goes in as it is, +/// braces and all. #[test] fn the_prompt_is_translated_and_still_names_its_file() { let png = adobe_png(); - for (code, sentence, embedded, working) in [ + for (code, sentence, use_embedded, convert) in [ ( "en", "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", - "Embedded: Adobe RGB", - "Working: sRGB", + "Use the embedded profile (Adobe RGB", + "Convert to the working space (sRGB", ), ( "de", "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", - "Eingebettet: Adobe RGB", - "Arbeitsfarbraum: sRGB", + "Eingebettetes Profil verwenden (Adobe RGB", + "In den Arbeitsfarbraum konvertieren (sRGB", ), ( "fr", "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", - "IncorporéNBSP: Adobe RGB", - "Espace de travailNBSP: sRGB", + "Utiliser le profil incorporé (Adobe RGB", + "Convertir vers l'espace de travail (sRGB", ), ( "ja", "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", - "埋め込み: Adobe RGB", - "作業用スペース: sRGB", + "埋め込みプロファイルを使用 (Adobe RGB", + "作業用スペースに変換 (sRGB", ), ] { let services = Services { @@ -177,12 +178,18 @@ fn the_prompt_is_translated_and_still_names_its_file() { let lang = crate::i18n::Lang::from_code(code).unwrap(); crate::i18n::with_language(lang, || app.open_file("/pics/shot {mode}.png", &png)).unwrap(); assert!(app.session.documents().is_empty(), "{code}: held until answered"); - let message = app.ui.dialogs[0].fields.get("message").and_then(Value::as_str).unwrap().to_string(); - let lines: Vec<&str> = message.lines().collect(); - assert_eq!(lines.len(), 3, "{code}: {message}"); - assert_eq!(lines[0], sentence.replace("NBSP", "\u{a0}"), "{code}"); - assert!(lines[1].starts_with(&embedded.replace("NBSP", "\u{a0}")), "{code}: {}", lines[1]); - assert!(lines[2].starts_with(&working.replace("NBSP", "\u{a0}")), "{code}: {}", lines[2]); + let fields = app.ui.dialogs[0].fields.clone(); + let message = fields.get("message").and_then(Value::as_str).unwrap().to_string(); + assert_eq!(message, sentence.replace("NBSP", "\u{a0}"), "{code}: one line, the profiles are on the choices"); + let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string(); + let (use_label, convert_label) = crate::i18n::with_language(lang, || { + ( + crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))]), + crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))]), + ) + }); + assert!(use_label.starts_with(use_embedded), "{code}: {use_label}"); + assert!(convert_label.starts_with(convert), "{code}: {convert_label}"); } } @@ -253,3 +260,40 @@ fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { assert_eq!(asked(&h), "d.png"); } } + +/// "Remember this choice": one answer opens the files still waiting with it, saves it as the +/// Color Settings policy and stops asking, so the next mismatched file opens without a prompt. +#[test] +fn remember_answers_the_waiting_files_and_stops_asking() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + click(&mut h, CONVERT); + click(&mut h, "Remember this choice for future images"); + click(&mut h, "OK"); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt is left"); + let mut opened = docs(&h); + opened.sort(); + assert_eq!(opened, ["a.png", "b.png", "c.png"], "background {background}"); + for name in ["a.png", "b.png", "c.png"] { + assert_eq!(profile(&h, name).as_deref(), Some("sRGB IEC61966-2.1"), "background {background}: {name} converted"); + } + let settings = &h.state().session.color.settings; + assert_eq!(settings.policy(photocraft_doc::ColorMode::Rgb), photocraft_engine::color_cmds::Policy::Convert); + assert!(!settings.ask_on_mismatch, "background {background}"); + h.state_mut().open_file("/pics/d.png", &adobe_png()).unwrap(); + settle(&mut h); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: the next file doesn't ask"); + assert_eq!(profile(&h, "d.png").as_deref(), Some("sRGB IEC61966-2.1"), "background {background}"); + } +} + +/// Without Remember, OK answers only the file asked about. +#[test] +fn without_remember_the_next_file_still_asks() { + let mut h = harness(false); + open_three(&mut h); + answer(&mut h, CONVERT); + assert_eq!(h.state().ui.dialogs.len(), 1); + assert!(h.state().session.color.settings.ask_on_mismatch); +} diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index cec2a8c289..e055cdd546 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -34,7 +34,7 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { fn open_together(h: &mut Harness<'static, PhotocraftApp>, n: usize) { let message = "The document “shot.png” has an embedded color profile that does not match the current RGB working space."; for _ in 0..n { - crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked"); + crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked", serde_json::json!({})); } for _ in 0..100 { h.run_steps(2); diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index b08546a51d..e233396cfd 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -454,6 +454,38 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #5092: a Puppet Warp session left open does not follow the user to a new document. + #[test] + fn session_is_dropped_when_another_document_becomes_active() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_some()); + app.session.execute("file.new", json!({"width": 32, "height": 32, "depth": 8})).unwrap(); + assert!(crate::distort_ui::display_doc(&app, 1).is_some()); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_none()); + assert!(crate::distort_ui::display_doc(&app, 1).is_none()); + // Closing the document it was started on drops it too. + assert!(app.session.set_active(0)); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_some()); + assert!(app.session.close(0).is_some()); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_none()); + } + /// #4440: ⌘Z / ⌘⇧Z undo and redo pin edits while Puppet Warp is open: placing a pin, /// dragging one and removing one are a step each; a click that moves nothing isn't one. #[test] diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index ff5f106bde..7f90860065 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -93,6 +93,17 @@ pub struct GuideDrag { pub vertical: bool, pub index: Option, pub pos: f64, + /// Where the pointer went down, on the guide's axis: released there, an existing guide is + /// clicked (selected), not moved. + pub press: f64, +} + +impl GuideDrag { + /// An existing guide taken at document point `p`. + pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { + let at = if vertical { p[0] } else { p[1] }; + Self { vertical, index: Some(index), pos: at, press: at } + } } /// Canvas area left after the rulers. @@ -159,6 +170,12 @@ fn guide_segment(xf: &ViewXform, clip: Rect, vertical: bool, pos: f64) -> [Pos2; if vertical { [xf.to_screen(pos, lo), xf.to_screen(pos, hi)] } else { [xf.to_screen(lo, pos), xf.to_screen(hi, pos)] } } +/// The cursor over a guide: the arrow across it as the view shows it. A vertical guide moves left +/// and right in the document; a rotated view turns that, as it turns the guide (#3277). +pub fn guide_cursor(xf: &ViewXform, vertical: bool) -> egui::CursorIcon { + crate::tool_cursor::resize(if vertical { [1.0, 0.0] } else { [0.0, 1.0] }, xf) +} + pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let clip = painter.clip_rect(); let drag = app.guide_drag; @@ -172,8 +189,13 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, styled_line(painter, a, b, Stroke::new(1.0, color), style); }; // A guide given a colour in New Guide keeps it; the rest use the preference colour (or, while - // New Guide Layout previews, the colour it will set). - let own = |vertical: bool, p: f64| guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b)); + // New Guide Layout previews, the colour it will set). The selected guide is Photoshop's blue. + let own = |vertical: bool, p: f64| { + if crate::guide_select::is_selected(app, vertical, p) { + return crate::guide_select::SELECTED_COLOR; + } + guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b)) + }; if app.ui.extras.guides || preview.is_some() { for (vertical, list) in [(true, &guides.vertical), (false, &guides.horizontal)] { for (i, p) in list.iter().enumerate() { @@ -213,16 +235,26 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { None } -/// Finish a guide drag: create, move, or delete (dropped outside the canvas). +/// Finish a guide drag: create, move, or delete (dropped outside the canvas). An existing guide +/// released where it was pressed is a click: it's selected and stays put, as in Photoshop; a +/// moved one stays selected. pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { let Some(size) = app.session.active().map(|s| s.doc.size) else { return }; + if let Some(i) = d.index + && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 + { + crate::guide_select::select(app, d.vertical, i); + return; + } let extent = if d.vertical { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; let inside = d.pos >= 0.0 && d.pos <= extent; let pos = (d.pos * 1000.0).round() / 1000.0; let _ = match (d.index, inside) { (None, true) => app.run("view.newGuide", json!({"orientation": orientation, "position": pos})), - (Some(i), true) => app.run("view.moveGuide", json!({"orientation": orientation, "index": i, "position": pos})), + (Some(i), true) => app + .run("view.moveGuide", json!({"orientation": orientation, "index": i, "position": pos})) + .inspect(|_| crate::guide_select::select(app, d.vertical, i)), (Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})), (None, false) => Ok(serde_json::Value::Null), }; @@ -402,7 +434,7 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; - app.guide_drag = Some(GuideDrag { vertical, index: None, pos }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos }); } if resp.drag_stopped() && let Some(d) = app.guide_drag.take() @@ -757,14 +789,14 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); app.sync_views(); - finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0 }); - finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0 }); // off canvas: ignored + finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0 }); + finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0 }); // off canvas: ignored assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![50.0]); assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty()); assert_eq!(guide_at(&app, 51.0, 10.0), Some((true, 0))); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0 }); assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![120.0]); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0 }); assert!(app.session.active().unwrap().doc.guides.vertical.is_empty()); app.ui.extras.lock_guides = true; assert_eq!(guide_at(&app, 0.0, 0.0), None); @@ -890,6 +922,20 @@ mod tests { } } + /// The cursor over a guide turns with the view as the guide does: on a view turned 90°, a + /// vertical guide shows as a horizontal line and moves up and down. + #[test] + fn guide_cursors_turn_with_a_rotated_view() { + use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let level = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // View rotation, flip → the cursor over a vertical guide, over a horizontal guide. + for (rotation, flip, want) in [(0.0, false, [H, V]), (90.0, false, [V, H]), (45.0, false, [NwSe, NeSw]), (0.0, true, [H, V])] { + let xf = ViewXform { rotation, flip, ..level }; + assert_eq!([guide_cursor(&xf, true), guide_cursor(&xf, false)], want, "view turned {rotation}°, flip {flip}"); + } + } + #[test] fn ruler_range_is_normalised_when_flipped() { // #1814: with View > Flip Horizontal, `to_doc` negates x, so the raw diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 0a64437214..4336315dba 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -39,19 +39,17 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> { ctx.data_mut(|d| d.remove_temp::>(log_id())).unwrap_or_default() } -/// Alternative default shortcuts Photoshop gives some commands besides the one its menu shows: -/// (command, shortcut). Edit › Fill… is Shift+F5 and also Shift+Backspace (Shift+Delete on a Mac). -pub const SECONDARY: &[(&str, &str)] = &[("edit.fill", "Shift+Backspace")]; - /// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command: /// command and shell shortcuts, then the menu catalogue's for live items without their own, -/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X +/// then Edit › Keyboard Shortcuts assignments to any other menu item, then the commands' +/// alternate keys (#3665: F3 is Copy as well as ⌘C, see +/// [`photocraft_engine::prefs::ALTERNATE_SHORTCUTS`]), which a main key of another command wins. D and X /// (`tools.defaultColors` / `tools.swapColors`) and the fill keys are commands like any other, so /// their overrides apply. Held temporary tools (Space…) are not here: see [`crate::hold_keys`]. pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let prefs = app.session.prefs(); let action_ids: Vec = app.session.actions.list.iter().map(|a| crate::actions::shortcut_id(&a.name)).collect(); - let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None))); + let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None).map(str::to_string))); // The Keyboard Shortcuts dialog lists Photoshop's Window › item (`window.panel.*`) // in place of the shell command it runs (`window.toggle.*`, see `menus::panel_alias`), so an // edit to that row reassigns or removes the shell command's default too (#1272). @@ -61,8 +59,8 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { crate::menus::panel_alias(k) == Some(id) && crate::menu_catalog::CATALOG.iter().any(|c| c.3 == k.as_str() && c.2.is_some() && c.2 == sc) }) }; - let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc) })); - let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut))); + let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc).map(str::to_string) })); + let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut).map(str::to_string))); let own: std::collections::HashSet<&str> = crate::menus::UI_COMMANDS .iter() .map(|c| c.0) @@ -71,7 +69,7 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let catalog = crate::menu_catalog::CATALOG .iter() .filter(|(_, _, sc, id)| sc.is_some() && !own.contains(id) && crate::menus::is_live(id)) - .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc))); + .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc).map(str::to_string))); let overrides = prefs .shortcuts .iter() @@ -82,17 +80,32 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()) && !crate::hold_keys::is_temporary(id) }) - .map(|(id, sc)| (id.as_str(), Some(sc.as_str()))); - // Photoshop's second shortcuts, kept while the command's main one is the default. - let secondary = SECONDARY.iter().filter(|(id, _)| !prefs.shortcuts.contains_key(*id)).map(|&(id, sc)| (id, Some(sc))); + .map(|(id, sc)| (id.as_str(), Some(sc.clone()))); // A key the user assigned in Edit › Keyboard Shortcuts belongs to that command, ahead of any // default that still names it (#1272): OK clears the old owner's row, but that row can be // another id for the same item, or a default the dialog doesn't list. - let mut candidates: Vec<(&str, Option<&str>)> = actions.chain(ui).chain(engine).chain(catalog).chain(overrides).chain(secondary).collect(); - candidates.sort_by_key(|(id, sc)| !(sc.is_some() && prefs.shortcuts.get(*id).map(String::as_str) == *sc)); + let mut candidates: Vec<(&str, Option, bool)> = actions + .chain(ui) + .chain(engine) + .chain(catalog) + .chain(overrides) + .map(|(id, sc)| { + let user = sc.is_some() && prefs.shortcuts.get(id) == sc.as_ref(); + (id, sc, user) + }) + .collect(); + // Each command's alternates after every main key, the user's ahead of the defaults. + let alternate_ids: std::collections::BTreeSet<&str> = + photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().map(|(id, _)| *id).chain(prefs.alternate_shortcuts.keys().map(String::as_str)).collect(); + for id in alternate_ids { + let main = prefs.shortcut(id, crate::shortcuts::default_shortcut(id).as_deref()).map(str::to_string); + let user = prefs.alternate_shortcuts.contains_key(id); + candidates.extend(prefs.alternates(id, main.as_deref()).into_iter().map(|k| (id, Some(k), user))); + } + candidates.sort_by_key(|(_, _, user)| !user); let mut all: Vec<(String, KeyboardShortcut)> = Vec::new(); - for (id, sc) in candidates { - let Some(sc) = sc.and_then(parse) else { continue }; + for (id, sc, _) in candidates { + let Some(sc) = sc.as_deref().and_then(parse) else { continue }; if !all.iter().any(|(_, b)| *b == sc) { all.push((id.to_string(), sc)); } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 458f22c050..2827671a84 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -315,6 +315,47 @@ fn a_disabled_shortcut_uses_the_command_id_translation() { }); } +/// #3665: Photoshop's second keys reach their command beside its main key: F2/F3/F4 Cut, Copy +/// and Paste, Shift+F7 Inverse, ⌘+ Zoom In, Shift+Backspace Fill. +#[test] +fn alternate_keys_run_their_command() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + set_dry_run(&h.ctx, true); + for (key, want) in [ + ("F2", "edit.cut"), + ("F3", "edit.copy"), + ("Cmd+C", "edit.copy"), + ("F4", "edit.paste"), + ("Shift+F7", "select.inverse"), + ("Cmd+Shift+I", "select.inverse"), + ("Cmd++", "view.zoomIn"), + ("Shift+Backspace", "edit.fill"), + ] { + press(&mut h, key); + let log = take_log(&h.ctx); + assert!(matches!(log.as_slice(), [(id, _)] if id == want), "{key}: {log:?}"); + } + // Overriding Copy's main key keeps F3; ⌘C no longer copies. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("edit.copy".into(), "Cmd+K".into()); + }); + press(&mut h, "F3"); + assert!(matches!(take_log(&h.ctx).as_slice(), [(id, _)] if id == "edit.copy")); + // A key a user gives another command wins over a default alternate. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("layer.renameLayer".into(), "F2".into()); + }); + assert!(bindings(h.state()).iter().any(|(id, sc)| id == "layer.renameLayer" && *sc == parse("F2").unwrap())); + assert!(!bindings(h.state()).iter().any(|(id, sc)| id == "edit.cut" && *sc == parse("F2").unwrap())); + // A removed alternate no longer fires. + h.state_mut().session.edit_prefs(|p| { + p.alternate_shortcuts.insert("edit.paste".into(), Vec::new()); + }); + press(&mut h, "F4"); + assert!(take_log(&h.ctx).is_empty()); +} + /// #352: ⌥[ ⌥] ⌥, ⌥. and ⇧⌥[ walk the Layers panel's rows from the keyboard. #[test] fn alt_brackets_walk_the_layers_panel() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bd3b1fe00c..a2f3f17c1b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -98,6 +98,13 @@ pub fn effective_shortcut(app: &PhotocraftApp, id: &str, default: Option<&str>) app.session.prefs().shortcut(id, default).map(str::to_string) } +/// A command's further keys in effect besides its main one (#3665): the user's, else +/// Photoshop's (F3 for Copy, Shift+F7 for Inverse…). Menus show only the main key. +pub fn effective_alternates(app: &PhotocraftApp, id: &str) -> Vec { + let main = effective_shortcut(app, id, default_shortcut(id).as_deref()); + app.session.prefs().alternates(id, main.as_deref()) +} + /// Default (unmodified) shortcut of a command id. pub fn default_shortcut(id: &str) -> Option { crate::menus::UI_COMMANDS @@ -189,10 +196,10 @@ fn new_layer_without_dialog(app: &mut PhotocraftApp, ctx: &egui::Context) -> boo fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { for id in NAV_COMMANDS { - let sc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| parse(app.session.prefs().shortcut(id, c.3)?)); - if let Some(sc) = sc - && consume(ctx, &sc) - { + let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| app.session.prefs().shortcut(id, c.3)); + let keys: Vec = + main.into_iter().map(str::to_string).chain(app.session.prefs().alternates(id, main)).filter_map(|k| parse(&k)).collect(); + if keys.iter().any(|sc| consume(ctx, sc)) { if crate::menus::is_enabled(app, id) { let _ = crate::menus::invoke(app, ctx, id, json!({})); } @@ -334,9 +341,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_some() { return; } - // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does an open - // dropdown list, whose letters pick an option rather than a tool (#1481). - if crate::menu_nav::is_open(ctx) || crate::widgets::dropdown_list_open(ctx) { + // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar + // once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters + // pick an option rather than a tool (#1481). + if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return; } // ⌘Z in a filter or adjustment dialog steps the dialog's settings, not the document (#4697). @@ -492,6 +500,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // A selected guide: ⌫/Delete removes it (`guide_select`), never Edit › Clear's pixels. + if crate::guide_select::keys(app, ctx) { + return; + } // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the // Hand key is held, before F's own Cycle Screen Mode. if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { @@ -628,6 +640,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); crate::spring_tools::pressed(app, t.key(), before, plain, now); + if app.ui.spring.is_none() { + // Permanent key choices own the tool; held-key switches keep the pen marker. + app.stylus.tool_before_eraser = None; + } return; } } @@ -748,7 +764,7 @@ mod tests { app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap(); let history = app.session.active().unwrap().history.past_len(); for (x, y) in [(10.0, 10.0), (50.0, 10.0), (50.0, 50.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } let ctx = egui::Context::default(); @@ -781,7 +797,7 @@ mod tests { app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); app.ui.tool = crate::state::Tool::Pen; for (x, y) in [(10.0, 10.0), (50.0, 10.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } app.session.prefs.edit(|p| p.shortcuts.insert("edit.undo".into(), "Cmd+Shift+Y".into())); @@ -1077,8 +1093,11 @@ mod tests { let all = photocraft_engine::command_specs() .iter() .filter_map(|c| c.shortcut.map(|s| (c.id, s))) - .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); + .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))) + // Photoshop's second keys (#3665) are taken by no other command either. + .chain(photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().copied()); for (id, s) in all { + assert!(parse(s).is_some(), "{id}: {s}"); if let Some(prev) = seen.insert(s, id) { panic!("shortcut {s} bound to both {prev} and {id}"); } diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 48b2f0a72f..deee9a7d17 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -287,6 +287,14 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } + // So has the Filter Gallery. + if f.command == "filter.filterGallery" { + if let Err(e) = crate::gallery_ui::open_smart_filter(app, photocraft_doc::LayerId(layer), index) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } if !crate::filter_dialog::has_dialog(&f.command) { return; } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 56fecd01eb..b41383c028 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -2,8 +2,8 @@ //! document, snaps tool gestures (Move tool, marquees, crop, shapes, pen anchors, Free Transform //! handles and guides), and draws smart guides (magenta alignment lines) while layers move. //! -//! Snapping is on when View › Snap (⇧⌘;) is; holding Ctrl while dragging turns it off for that -//! drag, as in Photoshop. Smart guides follow View › Show › Smart Guides and also snap the Move +//! Snapping is on when View › Snap (⇧⌘;) is; holding Ctrl while dragging turns it off, except when +//! Ctrl is the corner-distortion modifier. Smart guides follow View › Show › Smart Guides and also snap the Move //! tool to other layers' edges and centres. The threshold is 8 screen pixels at the current zoom. use egui::{Color32, Stroke, pos2}; @@ -22,6 +22,8 @@ pub const SNAP_PX: f64 = 8.0; pub enum Gesture { /// A point (marquee corner, crop or shape corner, pen anchor, transform handle). Point, + /// A transform corner: distortion snaps the corner itself, keeping the handle grab offset. + TransformCorner { index: usize, point: [f64; 2] }, /// Layers being moved: their bounds at the start of the drag. Move { rect: [f64; 4] }, /// Free Transform box dragged from inside: its bounds at the start of the drag. @@ -54,7 +56,14 @@ pub fn options(app: &PhotocraftApp) -> SnapOptions { crate::rulers::grid_major_px(g, d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64], ppi) .map(|step| step / g.subdivisions.max(1) as f64) }); - SnapOptions { guides: st.guides && e.guides, grid: st.grid && e.grid, layers: st.layers, document: st.document_bounds, selection: true, grid_step } + SnapOptions { + guides: st.guides && e.guides && app.ui.view.shows(app.ui.view.show.canvas_guides), + grid: st.grid && e.grid, + layers: st.layers, + document: st.document_bounds, + selection: true, + grid_step, + } } /// Snap tolerance in document pixels at the current zoom. @@ -154,6 +163,14 @@ fn override_held(mods: egui::Modifiers) -> bool { mods.ctrl && !mods.mac_cmd } +fn corner_distorts(app: &PhotocraftApp, gesture: &Gesture, mods: egui::Modifiers) -> bool { + matches!(gesture, Gesture::TransformCorner { index, .. } if crate::transform_tool::corner_distorts(app, *index, mods)) +} + +fn override_for_gesture(app: &PhotocraftApp, mods: egui::Modifiers) -> bool { + override_held(mods) && !(mods.command && app.prefs_rt.snap.as_ref().is_some_and(|s| corner_distorts(app, &s.gesture, mods))) +} + /// The Move tool's press at `p` picked other layers (Auto-Select): the drag moves those, so its /// box, what it snaps and what it leaves out of the targets are theirs, not the ones selected /// before (a group or an empty layer selected before had no box at all). @@ -174,12 +191,14 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { + let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { + crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + _ => None, + }; if t.mode == crate::state::TransformMode::Distort { - // Distort is freehand corner placement (fitting an image onto a screen, say): guides - // and edges pulling the corners about only get in the way. - None + corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - Some((Gesture::Point, vec![LayerId(t.layer)])) + corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) } else if in_quad(&t.quad, p) { Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) } else { @@ -220,6 +239,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { }; let Some((gesture, exclude)) = gesture else { return }; let mut targets = if snap_on(app) { build(app, &exclude, false) } else { SnapTargets::default() }; + if app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::Distort) { + // Explicit Distort fits corners to guides; preserve free placement near layer edges. + targets = targets.filtered(|k| k == SnapKind::Guide); + } if gesture == Gesture::Guide { // A guide never snaps to itself (or other guides). targets = targets.filtered(|k| k != SnapKind::Guide); @@ -262,6 +285,12 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 } let (out, lines) = match &snap.gesture { Gesture::Point => crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)), + Gesture::TransformCorner { point, .. } if corner_distorts(app, &snap.gesture, mods) => { + let proposed = [point[0] + p[0] - snap.start[0], point[1] + p[1] - snap.start[1]]; + let (corner, lines) = snap.targets.snap_point(proposed, tol); + ([p[0] + corner[0] - proposed[0], p[1] + corner[1] - proposed[1]], lines) + } + Gesture::TransformCorner { .. } => snap.targets.snap_point(p, tol), Gesture::Guide => snap.targets.snap_point(p, tol), Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; @@ -275,6 +304,18 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 out } +/// A corner stopped by the transform's convexity constraint is not aligned to the proposed +/// target. Keep only the lines the final corner actually reaches, after the transform applies. +pub(crate) fn retain_transform_lines(app: &mut PhotocraftApp, mods: egui::Modifiers) { + let Some(snap) = app.prefs_rt.snap.as_ref() else { return }; + let Gesture::TransformCorner { index, .. } = snap.gesture else { return }; + if !corner_distorts(app, &snap.gesture, mods) { + return; + } + let Some(point) = app.ui.transform.as_ref().and_then(|t| t.quad.get(index)).copied() else { return }; + app.prefs_rt.snap_lines.retain(|line| (if line.vertical { point[0] } else { point[1] } - line.pos).abs() < 1e-6); +} + /// Snap a crop handle being dragged: the handle itself (the frame's edge or corner), not the /// pointer, which grabbed it up to the grab radius away (#1919: snapping the pointer left a strip /// as wide as that offset between the frame and the edge it showed as snapped to). With Auto @@ -374,8 +415,9 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { - s.disabled = override_held(mods); + s.disabled = disabled; } let q = apply(app, [x, y], mods); let p = pixel_round(app, q); @@ -385,8 +427,9 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { - s.disabled = override_held(mods); + s.disabled = disabled; } let q = apply(app, [x, y], mods); let p = pixel_round(app, q); @@ -645,7 +688,302 @@ mod tests { } #[test] - fn distort_mode_never_snaps() { + fn distort_corner_snaps_from_positive_grab_offset() { + distort_corner_snaps_from_grab_offset([4.0, 3.0]); + } + + #[test] + fn distort_corner_snaps_from_negative_grab_offset() { + distort_corner_snaps_from_grab_offset([-4.0, -3.0]); + } + + fn distort_corner_snaps_from_grab_offset(offset: [f64; 2]) { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + let press = [before[1][0] + offset[0], before[1][1] + offset[1]]; + // The proposed corner is (108, 32); the pointer keeps its original grab offset. + let release = [108.0 + offset[0], 32.0 + offset[1]]; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: press[0], y: press[1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: release[0], y: release[1], pressure: 1.0 }, m); + let moved = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(moved[1], [110.0, 30.0], "grab offset {offset:?}"); + assert_eq!([moved[0], moved[2], moved[3]], [before[0], before[2], before[3]]); + assert!(app.prefs_rt.snap_lines.iter().any(|l| l.vertical && l.pos == 110.0 && l.kind == SnapKind::Guide)); + assert!(app.prefs_rt.snap_lines.iter().any(|l| !l.vertical && l.pos == 30.0 && l.kind == SnapKind::Guide)); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: release[0], y: release[1] }, m); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, moved); + assert!(app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty()); + } + + fn app_with_corner_guides(menu: &str) -> PhotocraftApp { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), menu, json!({})).unwrap(); + app + } + + fn move_corner(app: &mut PhotocraftApp, index: usize, offset: [f64; 2], end: [f64; 2], down: egui::Modifiers, moving: egui::Modifiers) { + let corner = app.ui.transform.as_ref().unwrap().quad[index]; + crate::canvas::tool_event(app, ToolEvent::Down { x: corner[0] + offset[0], y: corner[1] + offset[1], pressure: 1.0 }, down); + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0] + offset[0], y: end[1] + offset[1], pressure: 1.0 }, moving); + } + + #[test] + fn distort_corner_respects_guide_axes_tolerance_and_toggles() { + for (snap, shown, guides, end, expected) in [ + (true, true, true, [108.0, 20.0], [110.0, 20.0]), + (true, true, true, [100.0, 32.0], [100.0, 30.0]), + (true, true, true, [100.0, 20.0], [100.0, 20.0]), + (false, true, true, [108.0, 32.0], [108.0, 32.0]), + (true, false, true, [108.0, 32.0], [108.0, 32.0]), + (true, true, false, [108.0, 32.0], [108.0, 32.0]), + ] { + let mut app = app_with_corner_guides("edit.transform.distort"); + app.ui.extras.snap = snap; + app.ui.extras.guides = shown; + app.ui.view.snap_to.guides = guides; + move_corner(&mut app, 1, [4.0, 3.0], end, egui::Modifiers::NONE, egui::Modifiers::NONE); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], expected, "Snap: {snap}, shown: {shown}, guides: {guides}, proposed: {end:?}"); + assert!(app.prefs_rt.snap_lines.iter().all(|line| (if line.vertical { expected[0] } else { expected[1] } - line.pos).abs() < 1e-6)); + } + } + + #[test] + fn free_transform_command_corner_snaps_with_the_real_grab_offset() { + let windows = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let mac = egui::Modifiers { command: true, mac_cmd: true, ..egui::Modifiers::NONE }; + for m in [windows, mac] { + for offset in [[4.0, 3.0], [-4.0, -3.0], [0.0, 0.0]] { + for down in [m, egui::Modifiers::NONE] { + let mut app = app_with_corner_guides("edit.freeTransform"); + let before = app.ui.transform.as_ref().unwrap().quad; + move_corner(&mut app, 1, offset, [108.0, 32.0], down, m); + let moved = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(moved[1], [110.0, 30.0], "{m:?}, press {down:?}, offset {offset:?}"); + assert_eq!([moved[0], moved[2], moved[3]], [before[0], before[2], before[3]]); + assert_eq!(app.prefs_rt.snap_lines.len(), 2); + assert!(!app.prefs_rt.snap.as_ref().unwrap().disabled); + } + } + } + } + + #[test] + fn control_alone_still_disables_snapping_in_menu_distort() { + let mut app = app_with_corner_guides("edit.transform.distort"); + let ctrl = egui::Modifiers { ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], ctrl, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], [108.0, 32.0]); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled); + assert!(app.prefs_rt.snap_lines.is_empty()); + } + + #[test] + fn skew_control_corner_keeps_its_axis_instead_of_snapping_as_distort() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + for legacy in [false, true] { + for (menu, expected, snapping) in [ + ("edit.transform.skew", [[40.0, 40.0], [108.0, 40.0], [90.0, 80.0], [40.0, 80.0]], false), + ("edit.transform.distort", [[40.0, 40.0], [110.0, 30.0], [90.0, 80.0], [40.0, 80.0]], true), + ("edit.transform.perspective", [[22.0, 40.0], [108.0, 40.0], [90.0, 80.0], [40.0, 80.0]], false), + ] { + let mut app = app_with_corner_guides(menu); + app.run("prefs.set", json!({"path": "general.useLegacyFreeTransform", "value": legacy})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + let prefs = serde_json::to_value(app.session.prefs()).unwrap(); + // Ctrl arrives after the raw, off-centre press, as on the actual canvas path. + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], egui::Modifiers::NONE, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "{menu}, legacy {legacy}"); + assert_eq!(!app.prefs_rt.snap.as_ref().unwrap().disabled, snapping, "{menu}"); + assert_eq!(app.prefs_rt.snap_lines.len(), if snapping { 2 } else { 0 }, "{menu}"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 35.0 }, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "release preserves {menu}"); + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none() && app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "cancel preserves {menu}'s document"); + assert_eq!(app.session.active().unwrap().history.past_len(), past, "cancel preserves {menu}'s history"); + assert_eq!(serde_json::to_value(app.session.prefs()).unwrap(), prefs, "{menu} does not change preferences"); + } + } + } + + #[test] + fn skew_control_corner_on_editable_type_slides_the_whole_edge() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + for legacy in [false, true] { + let mut app = app_with_box(); + app.run("type.create", json!({"x": 40, "y": 60, "text": "Hello"})).unwrap(); + app.run("prefs.set", json!({"path": "general.useLegacyFreeTransform", "value": legacy})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.transform.skew", json!({})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + let end = [before[1][0] + 18.0, before[1][1] - 8.0]; + let doc = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + move_corner(&mut app, 1, [2.0, 1.0], end, egui::Modifiers::NONE, ctrl); + let mut expected = before; + expected[0][0] += 18.0; + expected[1][0] += 18.0; + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "type remains affine, legacy {legacy}"); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled && app.prefs_rt.snap_lines.is_empty()); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: end[0] + 2.0, y: end[1] + 1.0 }, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "release preserves the type's edge"); + crate::transform_tool::cancel(&mut app); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + } + + #[test] + fn transform_non_distortion_gestures_keep_control_snap_override() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let perspective = egui::Modifiers { alt: true, shift: true, ..ctrl }; + for (menu, press, m) in [ + ("edit.freeTransform", [65.0, 40.0], ctrl), // edge + ("edit.freeTransform", [65.0, 60.0], ctrl), // pivot + ("edit.freeTransform", [55.0, 52.0], ctrl), // inside + ("edit.freeTransform", [90.0, 40.0], perspective), + ("edit.transform.perspective", [90.0, 40.0], ctrl), + ("edit.transform.warp", [90.0, 40.0], ctrl), + ("edit.contentAwareScale", [90.0, 40.0], ctrl), + ] { + let mut app = app_with_corner_guides(menu); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: press[0], y: press[1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 108.0, y: 32.0, pressure: 1.0 }, m); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled, "{menu}, {press:?}, {m:?}"); + assert!(app.prefs_rt.snap_lines.is_empty(), "{menu}, {press:?}"); + } + } + + #[test] + fn editable_type_refusal_keeps_control_snap_override() { + let mut app = app_with_box(); + app.run("type.create", json!({"x": 40, "y": 60, "text": "Hello"})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let corner = app.ui.transform.as_ref().unwrap().quad[1]; + app.run("view.newGuide", json!({"orientation": "vertical", "position": corner[0] + 20.0})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": corner[1] - 10.0})).unwrap(); + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [0.0, 0.0], [corner[0] + 18.0, corner[1] - 8.0], ctrl, ctrl); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled); + assert!(app.prefs_rt.snap_lines.is_empty()); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(q[0][1], q[1][1], "editable type still scales, rather than distorting"); + } + + #[test] + fn a_foldover_constraint_never_claims_a_guide_alignment() { + let mut app = app_with_corner_guides("edit.transform.distort"); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + move_corner(&mut app, 0, [4.0, 3.0], [108.0, 98.0], egui::Modifiers::NONE, egui::Modifiers::NONE); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_ne!(q[0], [110.0, 100.0], "the guide intersection would fold the quad"); + assert_eq!([q[1], q[2], q[3]], [before[1], before[2], before[3]]); + for i in 0..4 { + let (a, b, c) = (q[i], q[(i + 1) % 4], q[(i + 2) % 4]); + assert!((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) > 0.0, "still convex: {q:?}"); + } + assert!(app.prefs_rt.snap_lines.is_empty(), "{:?}", app.prefs_rt.snap_lines); + } + + #[test] + fn a_snapped_distort_commits_one_undoable_transform() { + let mut app = app_with_corner_guides("edit.transform.distort"); + let before_doc = app.session.active().unwrap().doc.clone(); + let before_bounds = mover_bounds(&app); + let past = app.session.active().unwrap().history.past_len(); + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], egui::Modifiers::NONE, egui::Modifiers::NONE); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before_doc), "preview does not edit the document"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 35.0 }, egui::Modifiers::NONE); + crate::transform_tool::commit(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + let after_bounds = mover_bounds(&app); + assert_ne!(after_bounds, before_bounds); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(mover_bounds(&app), before_bounds); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(mover_bounds(&app), after_bounds); + } + + #[test] + fn transform_corner_press_is_not_adjusted_near_an_original_guide() { + for menu in ["edit.transform.distort", "edit.freeTransform"] { + let mut app = app_with_corner_guides(menu); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 95})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 45})).unwrap(); + let raw = ToolEvent::Down { x: 94.0, y: 43.0, pressure: 1.0 }; + let ToolEvent::Down { x, y, .. } = filter_event(&mut app, raw, egui::Modifiers::NONE) else { panic!("still a press") }; + assert_eq!([x, y], [94.0, 43.0], "{menu}: filtering must preserve the handle press"); + } + } + + #[test] + fn corner_near_original_guides_keeps_the_raw_press_in_the_canvas_path() { + for (menu, m) in + [("edit.transform.distort", egui::Modifiers::NONE), ("edit.freeTransform", egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE })] + { + let mut app = app_with_corner_guides(menu); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 95})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 45})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + // The command modifier is added after Down, including in Free Transform. + move_corner(&mut app, 1, [4.0, 3.0], [93.0, 43.0], egui::Modifiers::NONE, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(q[1], [95.0, 45.0], "{menu}"); + assert_eq!([q[0], q[2], q[3]], [before[0], before[2], before[3]]); + } + } + + #[test] + fn corner_distortion_does_not_snap_to_guides_hidden_by_view_flags() { + for menu in ["edit.transform.distort", "edit.freeTransform"] { + for hide_extras in [false, true] { + let mut app = app_with_corner_guides(menu); + if hide_extras { + app.ui.view.extras = false; + } else { + app.ui.view.show.canvas_guides = false; + } + let m = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], m, m); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], [108.0, 32.0], "{menu}, hide Extras: {hide_extras}"); + assert!(app.prefs_rt.snap_lines.is_empty()); + } + } + } + + #[test] + fn marquee_and_corner_snapping_share_the_canvas_guide_visibility() { + for (extras, canvas_guides, shown) in [(true, true, true), (false, true, false), (true, false, false)] { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + app.ui.tool = Tool::RectMarquee; + // Isolate guides: the mover's y=40 edge would otherwise be a valid fallback target. + app.ui.view.snap_to.layers = false; + app.ui.view.snap_to.document_bounds = false; + app.ui.view.snap_to.grid = false; + app.ui.view.extras = extras; + app.ui.view.show.canvas_guides = canvas_guides; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 108.0, y: 32.0 }, m); + let rect = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((rect.x0, rect.y0), (12, 12)); + assert_eq!((rect.x1, rect.y1), if shown { (110, 30) } else { (108, 32) }, "Extras: {extras}, Canvas Guides: {canvas_guides}"); + } + } + + #[test] + fn distort_corner_does_not_snap_to_layer_edges() { let mut app = app_with_box(); let ctx = egui::Context::default(); crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 42997fe62c..aeefb3ecbd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -389,6 +389,9 @@ pub struct Panels { /// Window › Brush Settings (F5): floating, like Photoshop's. #[serde(default)] pub brush_settings: bool, + /// Window › Brushes: dockable panel, like Photoshop's. + #[serde(default)] + pub brushes: bool, /// Window › Character / Paragraph: the Character | Paragraph dock group (#150). #[serde(default)] pub character: bool, @@ -427,6 +430,7 @@ impl Default for Panels { options_bar: true, status_bar: true, brush_settings: false, + brushes: false, character: false, toolbar_double: false, dock: true, @@ -620,6 +624,10 @@ pub struct ToolOptions { pub crop_custom_ratio_height: f32, #[serde(default = "yes")] pub crop_delete: bool, + /// Crop options bar › Fill: false = Background (Default), true = Content-Aware Fill (the area a + /// rotated or extending crop uncovers is filled from its surroundings, #3823). + #[serde(default)] + pub crop_content_aware: bool, /// Crop W x H x Resolution (`crop_ratio` = "whr", #2443): width and height as typed lengths /// with their unit ("4 in", "1024 px"; empty = unset), the resolution ("" = the document's) /// and its unit ("px/in" or "px/cm"). See `crop_size`. @@ -741,6 +749,10 @@ fn default_brush_tool() -> Tool { Tool::Brush } +fn default_layer_filter_mode() -> photocraft_color::BlendMode { + photocraft_color::BlendMode::Normal +} + fn one_u32() -> u32 { 1 } @@ -815,6 +827,7 @@ impl Default for ToolOptions { crop_custom_ratio_width: 0.0, crop_custom_ratio_height: 0.0, crop_delete: true, + crop_content_aware: false, crop_width: String::new(), crop_height: String::new(), crop_resolution: String::new(), @@ -1031,6 +1044,8 @@ pub struct DockTabs { pub history: usize, /// Character | Paragraph. pub character: usize, + /// Brushes presets. + pub brushes: usize, } /// Color panel state. @@ -1119,6 +1134,9 @@ pub struct UiState { /// Selection-tool context menu opened by a plain canvas right-click. #[serde(default)] pub canvas_tool_menu: Option, + /// The guide a Move-tool click selected (`guide_select`): drawn blue, Delete removes it. + #[serde(default)] + pub selected_guide: Option, /// The brush is a per-tool option (the Brush, the Eraser and every retouching tool each keep /// their own size, hardness, tip and dynamics, as in Photoshop): the tool whose brush the /// session brush holds, and the other tools' saved brushes. @@ -1134,12 +1152,24 @@ pub struct UiState { /// Direct Selection tool: selected anchors and the drag in progress (#790). #[serde(default)] pub direct_selection: crate::direct_select::DirectSelection, + /// A live rectangle's corner radius widget being dragged (TOOL-214-12). + #[serde(skip)] + pub corner_radius: Option, /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Layers panel filter type (#5048): kind (above), name or blend mode. + #[serde(default)] + pub layer_filter_by: crate::layer_filter_ui::FilterBy, + /// Layers panel name filter: part of a layer name, ignoring case; empty = all. + #[serde(default)] + pub layer_filter_name: String, + /// Layers panel blend-mode filter. + #[serde(default = "default_layer_filter_mode")] + pub layer_filter_mode: photocraft_color::BlendMode, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -1289,6 +1319,7 @@ impl Default for UiState { brush_picker_size: None, layer_menu: None, canvas_tool_menu: None, + selected_guide: None, brush_tool: Tool::Brush, tool_brushes: Vec::new(), clone_source: None, @@ -1307,9 +1338,13 @@ impl Default for UiState { artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layer_filter_by: Default::default(), + layer_filter_name: String::new(), + layer_filter_mode: default_layer_filter_mode(), pen: None, stroke_editor: None, direct_selection: Default::default(), + corner_radius: None, selected_path: None, panels: Panels::default(), views: Vec::new(), diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 749ab4a059..a18ddee640 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -68,11 +68,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut st = std::mem::take(&mut app.ui.presets_ui); st.sizes.entry(PANEL.to_string()).or_insert(DEFAULT_SIZE); let max_h = (ui.available_height() - 30.0).max(60.0); - let card_border = crate::theme::Tokens::get(ui.ctx()).card_border; - let events = browser(ui, &mut st, PANEL, &groups, Place { canvas, max_h, new_tip: tl!("Create new swatch") }, &mut |ui, r: Rect, gi, ii| { + // Flat, touching squares, as in Photoshop's Swatches panel (#5103). + let events = browser(ui, &mut st, PANEL, &groups, Place { canvas, max_h, new_tip: tl!("Create new swatch"), flush: true }, &mut |ui, r: Rect, gi, ii| { let c = table.get(gi).and_then(|g| g.get(ii)).copied().unwrap_or(Color32::GRAY); - ui.painter().rect_filled(r, CornerRadius::same(2), c); - ui.painter().rect_stroke(r, CornerRadius::same(2), egui::Stroke::new(1.0, card_border), egui::StrokeKind::Inside); + ui.painter().rect_filled(r, CornerRadius::ZERO, c); }); app.ui.presets_ui = st; let mods = ui.input(|i| i.modifiers); @@ -305,6 +304,20 @@ mod tests { h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0) } + /// #5103: the swatches touch, as in Photoshop's panel. A click just past the first cell's + /// right edge lands on the second swatch; with the preset grids' 4 px gap it hit nothing. + #[test] + fn swatches_touch_without_a_gap() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let second = app.session.presets.swatches.first().and_then(|g| g.items.get(1)).map(|s| s.name.clone()).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); + h.run_steps(2); + assert_eq!(h.state().ui.presets_ui.selected.get(PANEL), None); + let p = first_cell(&h); + click(&mut h, p + vec2(DEFAULT_SIZE / 2.0 + 1.0, 0.0), Modifiers::NONE); + assert_eq!(h.state().ui.presets_ui.selected.get(PANEL), Some(&second), "the second swatch, not a gap"); + } + /// Clicking a swatch while characters are selected recolours them, not just the foreground. #[test] fn clicking_a_swatch_recolours_selected_type() { diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index f7c9043821..61650a1182 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -86,20 +86,20 @@ fn tabs_in( ui: &mut Ui, id: egui::Id, area: Rect, - tabs: &[&str], + panel_ids: &[&str], selected: &mut usize, font: egui::FontId, pad: f32, min_w: f32, reorderable: bool, active: impl Fn(usize) -> bool, - mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc, &Response, bool), + mut paint_tab: impl FnMut(&Ui, Rect, usize, &str, std::sync::Arc, &Response, bool), ) -> StripOut { let t = Tokens::get(ui.ctx()); // Panel names are English keys; draw them in the UI language. - let names: Vec<&str> = tabs.iter().map(|n| tl!(n)).collect(); - let tabs = names.as_slice(); - let natural: Vec = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect(); + let names: Vec<&str> = panel_ids.iter().map(|n| tl!(n)).collect(); + let translated_tabs = names.as_slice(); + let natural: Vec = translated_tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect(); let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W); let mut x = area.left(); let mut out = StripOut { @@ -117,7 +117,8 @@ fn tabs_in( let sense = if reorderable { Sense::click_and_drag() } else { Sense::click() }; let mut drag: Option<(usize, bool)> = None; for &(i, w) in &f.shown { - let Some(name) = tabs.get(i) else { continue }; + let Some(name) = translated_tabs.get(i) else { continue }; + let panel_id = panel_ids[i]; let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height())); // The padding gives way (down to a third) before the label is cut. let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0)); @@ -125,7 +126,7 @@ fn tabs_in( let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at( ui, r, - |ui, resp| paint_tab(ui, r, i, galley, resp, active(i)), + |ui, resp| paint_tab(ui, r, i, panel_id, galley, resp, active(i)), ); let resp = if cut { resp.on_hover_text(*name) } else { resp }; if resp.drag_started() { @@ -160,7 +161,7 @@ fn tabs_in( if !f.overflow.is_empty() && f.overflow_width > 0.0 { let r = Rect::from_min_size(pos2(x, area.top()), vec2(f.overflow_width, area.height())); let mut picked = None; - overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), tabs, &f.overflow, &mut picked); + overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), translated_tabs, &f.overflow, &mut picked); if let Some(i) = picked { out.clicked = true; *selected = i; @@ -176,7 +177,7 @@ fn tabs_in( let before = match out.tabs.get(slot) { Some((b, _)) => Some(*b), // After the last tab on the strip: before the first overflowing one, if any. - None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < tabs.len()), + None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < panel_ids.len()), }; if released { out.reorder = Some((tab, before)); @@ -218,7 +219,7 @@ pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[ 40.0, reorderable, |i| i == sel && !collapsed, - |ui, r, i, galley, resp, active| { + |ui, r, i, _, galley, resp, active| { if active { ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card); } else if resp.hovered() { @@ -251,7 +252,7 @@ pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: 44.0, reorderable, |i| i == sel, - |ui, r, _, galley, resp, active| { + |ui, r, _, _, galley, resp, active| { // 2 pt between pills, as the old horizontal layout had. let r = Rect::from_min_max(r.min, pos2((r.right() - 2.0).max(r.left()), r.bottom())); if active { diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index c45baa2f0d..de10ca30f3 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -646,7 +646,7 @@ mod tests { for width in [760.0, 1100.0, 1440.0] { let mut w = Win::new(width); w.frame(vec![]); - let out = w.frame(vec![]); + w.frame(vec![]); let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}"); // The bar's bottom point is the panel's separator line, which the buttons leave visible. @@ -657,9 +657,18 @@ mod tests { for a in &widgets { assert!(a.right() <= min.left(), "{a:?} runs into the caption buttons at {width}"); } - // Wide windows show the whole group; the narrowest drops Discord (also Help › Discord). - let has_discord = texts(&out).iter().any(|(t, _)| t == "Discord"); - assert_eq!(has_discord, width > 1000.0, "Discord at {width}"); + } + } + + #[test] + fn the_title_bar_promotes_no_community_chat() { + // Issue #1726: only the always-visible title-bar button is removed. The start-screen + // and About buttons and Help › Discord remain available. + for width in [760.0, 1100.0, 1440.0] { + let mut w = Win::new(width); + w.frame(vec![]); + let out = w.frame(vec![]); + assert!(!texts(&out).iter().any(|(t, _)| t.contains("Discord")), "no Discord in the bar at {width}"); } } diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 0eacc731ae..5cd04d4f0b 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -40,12 +40,20 @@ pub enum HistSource { BelowLayer(LayerId), /// One layer on its own (the target of a destructive Image › Adjustments command). Layer(LayerId), + /// The gray plane a Curves target edits: a layer mask, a saved channel or the Quick Mask. + /// The dialog shows this plane's own levels, not the colour layers beneath (#5335). + Channel { layer: LayerId, surface: photocraft_engine::channel_cmds::GraySurface }, } fn tag(source: HistSource, space: ToneSpace) -> u64 { let (k, id) = match source { HistSource::BelowLayer(l) => (1u64, l.0), HistSource::Layer(l) => (2, l.0), + HistSource::Channel { layer, surface } => match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => (4, i as u64), + photocraft_engine::channel_cmds::GraySurface::QuickMask => (5, layer.0), + }, }; let s = match space { ToneSpace::Rgb => 0u64, @@ -68,6 +76,35 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut h = vec![[0u32; 256]; match space { + ToneSpace::Rgb => 4, + ToneSpace::Cmyk => 5, + ToneSpace::Lab => 3, + }]; + match source { + // The gray plane itself: its levels are what the curve edits, whatever the document's + // colour mode (#5335). + HistSource::Channel { layer, surface } => { + let surf = match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| &m.surface), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => doc.channels.get(i).map(|c| &c.surface), + photocraft_engine::channel_cmds::GraySurface::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + }; + if let Some(s) = surf + && let Some(bin) = h.first_mut() + { + *bin = gray_bins(s, doc.bounds()); + } + } + HistSource::BelowLayer(_) | HistSource::Layer(_) => { + composite_bins(doc, source, space, &mut h); + } + } + Histograms { tag: tag(source, space), ch: h } +} + +/// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -78,14 +115,9 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) HistSource::Layer(id) => { isolate(&mut d.layers, id); } + HistSource::Channel { .. } => {} } let img = photocraft_compose::thumbnail(&d, 384); - let n = match space { - ToneSpace::Rgb => 4, - ToneSpace::Cmyk => 5, - ToneSpace::Lab => 3, - }; - let mut h = vec![[0u32; 256]; n]; let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; for p in img.pixels.as_chunks::<4>().0 { if p[3] == 0 { @@ -115,7 +147,26 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) } } } - Histograms { tag: tag(source, space), ch: h } +} + +/// 256-bin histogram of a gray plane: its levels in 0–255, sampled like the engine's auto-tone +/// histograms so a big canvas costs no more than about a megapixel here (#5335). +fn gray_bins(surf: &photocraft_raster::Surface, area: photocraft_geom::Rect) -> [u32; 256] { + let mut h = [0u32; 256]; + let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as i32; + let mut y = area.y0; + while y < area.y1 { + let mut x = area.x0; + while x < area.x1 { + let v = (surf.sample_channel(x, y, 0).clamp(0.0, 1.0) * 255.0).round() as usize; + if let Some(bin) = h.get_mut(v) { + *bin += 1; + } + x += step; + } + y += step; + } + h } /// Cached histograms for a tone editor; recomputed only when something other than the editor's @@ -126,6 +177,7 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) let want = tag(source, space); let layer = match source { HistSource::BelowLayer(l) | HistSource::Layer(l) => l, + HistSource::Channel { layer, .. } => layer, }; let pixels_kept = view_only(st); if let Some((d, l, r, h)) = app.tone_hist.as_mut() @@ -362,6 +414,39 @@ mod tests { assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Cmyk).ch.len(), 5); assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Lab).ch.len(), 3); } + + /// #5335: the Curves histogram of a targeted gray plane describes that plane, not the + /// colour layers beneath it: a channel filled with 25% grey over a black layer shows a + /// spike at 25% grey, not at black. + #[test] + fn channel_histogram_describes_the_gray_plane() { + use photocraft_engine::channel_cmds::GraySurface; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); // white background + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let b = photocraft_geom::Rect::from_xywh(0, 0, 32, 32); + s.edit("fill-planes", |doc, active| { + doc.channels[0].surface.fill_rect(b, &[0.25]); + doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface.fill_rect(b, &[0.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.unwrap(); + let h = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb); + assert!(h[0][64] > 0, "a spike at 25% grey, got {:?}", &h[0][0..80]); + assert_eq!(h[0][0], 0, "not the black layer beneath"); + let m = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb); + assert!(m[0][0] > 0, "the hidden mask is black"); + // Each plane keeps its own cache entry: switching targets recomputes. + assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), + tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb)); + assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 6539ddb860..32f6aed82e 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -9,6 +9,8 @@ use std::sync::Arc; use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; +use crate::canvas::ViewXform; + mod hand; mod zoom; @@ -104,6 +106,21 @@ pub(crate) fn native_scale(ctx: &egui::Context) -> f32 { if cfg!(target_os = "macos") { ctx.zoom_factor() } else { ctx.pixels_per_point() } } +/// The resize arrow for a handle that resizes along document direction `d`, as the view shows it +/// (its rotation, flip and pixel aspect): the nearest of the four arrows. Free Transform (handles +/// and Warp splits), the type box, Content-Aware Move's box, the Artboard tool and guides pick +/// their arrows here. +pub(crate) fn resize(d: [f64; 2], xf: &ViewXform) -> CursorIcon { + let s = xf.map_vec(vec2(d[0] as f32, d[1] as f32)); + let angle = f64::from(s.y).atan2(f64::from(s.x)).rem_euclid(std::f64::consts::PI); + match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { + 0 => CursorIcon::ResizeHorizontal, + 1 => CursorIcon::ResizeNwSe, + 2 => CursorIcon::ResizeVertical, + _ => CursorIcon::ResizeNeSw, + } +} + /// The Hand tool's pointer: an open hand, a fist while it drags (`closed`). winit maps `Grab` and /// `Grabbing` to the four-arrow move cursor on Windows, so there it is a bitmap; macOS and the /// Linux themes already draw hands, and the OS moves those without waiting for a frame. diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 8a240fa70c..409d528b95 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -458,3 +458,26 @@ fn circle_bitmaps_evaluate_only_the_ring_and_match_every_pixel() { } } } + +/// The arrow is the handle's direction as the view shows it: turned with a rotated view and +/// mirrored with a flipped one. +#[test] +fn resize_follows_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let view = |rotation: f32, flip: bool| ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + zoom: 1.0, + center: [0.0, 0.0], + flip, + rotation, + aspect: 1.0, + }; + // Right, down-right, down and down-left in the document. + let directions = [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0]]; + for (rotation, flip, want) in + [(0.0, false, [H, NwSe, V, NeSw]), (90.0, false, [V, NeSw, H, NwSe]), (45.0, false, [NwSe, V, NeSw, H]), (0.0, true, [H, NeSw, V, NwSe])] + { + let got = directions.map(|d| resize(d, &view(rotation, flip))); + assert_eq!(got, want, "view turned {rotation}°, flip {flip}"); + } +} diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index eda03b305e..2e66ae07c1 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -17,6 +17,8 @@ pub enum Badge { Add, Subtract, Intersect, + /// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor). + Move, } impl Badge { @@ -105,6 +107,12 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Vec<[Pos2; 2]> { match b { Badge::Add => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]], Badge::Subtract => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)]], + Badge::Move => { + let a = h * 0.45; + let mut v = vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]]; + for d in [vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0)] { + let tip = c + d * h; + let side = vec2(-d.y, d.x) * a; + v.push([tip, tip - d * a + side]); + v.push([tip, tip - d * a - side]); + } + v + } Badge::Intersect => { let d = h * 0.8; vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 8ea78a4d52..4c0f61b817 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,5 +1,4 @@ -//! Tool options remembered between launches (#2814), as Photoshop keeps each tool's options-bar -//! settings across sessions. +//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -10,7 +9,28 @@ use serde_json::{Map, Value}; use crate::PhotocraftApp; -use crate::state::ToolOptions; +use crate::state::{Tool, ToolOptions}; + +/// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. +pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { + app.stylus.tool_before_eraser = None; + app.ui.spring = None; + app.ui.tool = tool; +} + +/// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's +/// eraser end do, so remember the tool they will return to instead. A different explicit choice +/// (or beginning a type edit) owns the tool and stands, just as in `spring_tools::tick`. +fn selected(app: &PhotocraftApp) -> Tool { + let tool = if app.ui.tool == Tool::Eraser { app.stylus.tool_before_eraser.unwrap_or(app.ui.tool) } else { app.ui.tool }; + if let Some(s) = &app.ui.spring + && tool == s.tool + && app.ui.text_edit.is_none() + { + return s.previous; + } + tool +} /// The options that differ from the defaults, as JSON (`Null` when none do). fn changed(o: &ToolOptions) -> Value { @@ -21,11 +41,24 @@ fn changed(o: &ToolOptions) -> Value { if diff.is_empty() { Value::Null } else { Value::Object(diff) } } -/// Remember the tool options in the preferences once the user lets go of the pointer. Cheap -/// when nothing changed: one struct compare per frame. Run every frame, so options set by the -/// control channel or a tool preset are remembered too. +/// Remember the selected tool and its options once the user lets go of the pointer. Run every +/// frame, so choices made through the control channel or a tool preset are remembered too. pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { - if ctx.input(|i| i.pointer.any_down()) || app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { + if ctx.input(|i| i.pointer.any_down()) { + return; + } + let tool = selected(app); + if app.prefs_rt.selected_tool != Some(tool) { + app.prefs_rt.selected_tool = Some(tool); + if let Ok(now) = serde_json::to_value(tool) + && app.session.prefs().selected_tool != now + { + app.session.prefs.edit(|p| p.selected_tool = now); + // prefs_ui::tick ran before the panels; ensure it can flush this new revision. + ctx.request_repaint(); + } + } + if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); @@ -35,8 +68,25 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } -/// Restore the remembered tool options at launch, field by field over the current ones. +/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { + persist(app, &egui::Context::default()); + crate::prefs_ui::save_preferences(app) +} + +/// Restore the selected tool before the first frame and its options field by field. pub fn restore(app: &mut PhotocraftApp) { + let saved = app.session.prefs().selected_tool.clone(); + if !saved.is_null() { + match serde_json::from_value::(saved) { + Ok(tool) => { + app.ui.tool = tool; + app.ui.remember_group_tool(); + } + Err(err) => log::warn!("Preferences: ignoring the remembered selected tool: {err}"), + } + } + app.prefs_rt.selected_tool = Some(app.ui.tool); let saved = app.session.prefs().tool_options.clone(); if let Some(saved) = saved.as_object() && let Ok(Value::Object(mut cur)) = serde_json::to_value(&app.ui.tool_options) diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 8de3fe4ec7..27e9622ab3 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -130,6 +130,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::Crop => reset!( crop_ratio, crop_delete, + crop_content_aware, crop_width, crop_height, crop_resolution, diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 81fc0fadcf..d35aac9040 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -145,14 +145,32 @@ struct Steps { settled: Option, undo: Vec, redo: Vec, + /// When the latest arrow-key nudge came (egui time) and the box it left. + nudged: Option<(f64, Step)>, + /// The nudge that moved the box since it last came to rest follows another one closely: it + /// joins that one's step instead of adding its own (#2259). + fold: bool, } impl Steps { + /// An arrow-key nudge at `now` moved the box from `before` to `after`. With `pause` (seconds, + /// `None`: nudges aren't bundled) it folds into the previous nudge's step when it came within + /// that time of it and found the box as that nudge left it, so a drag, an option edit, Undo + /// or Redo in between start a step of their own. + fn nudged(&mut self, before: &Step, after: Step, now: f64, pause: Option) { + self.fold = pause.is_some_and(|w| self.nudged.as_ref().is_some_and(|(at, left)| left == before && now >= *at && now - at <= w)); + self.nudged = pause.map(|_| (now, after)); + } + /// Record a step if the box changed since it last came to rest. fn settle(&mut self, now: Step) { if self.settled.as_ref() == Some(&now) { return; } + if std::mem::take(&mut self.fold) && self.settled.is_some() { + self.settled = Some(now); + return; + } if let Some(prev) = self.settled.replace(now) { self.undo.push(prev); self.redo.clear(); @@ -195,7 +213,8 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if matches!(layer.content, LayerContent::Adjustment(_)) && layer.mask.is_none() { return Err("Adjustment layers have nothing to transform".into()); } - let b = photocraft_engine::transform_cmds::transform_bounds(&doc, layer); + // With a selection the box is the selection's (#5297). + let b = photocraft_engine::transform_cmds::transform_frame(&doc, layer); if b.is_empty() { return Err("Could not transform: the layer is empty".into()); } @@ -410,7 +429,7 @@ fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { } /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the -/// box frames its content (within the selection) and previews the moving values in grey over the +/// box frames its content (or the selection) and previews the moving values in grey over the /// document with them vacated. fn begin_lone( app: &mut PhotocraftApp, @@ -420,7 +439,7 @@ fn begin_lone( target: serde_json::Value, ) -> Result<(), String> { use photocraft_engine::transform_cmds as tc; - let b = tc::target_bounds(&doc, &surf); + let b = tc::target_frame(&doc, &surf); if b.is_empty() { return Err("Could not transform: nothing is selected".into()); } @@ -776,6 +795,9 @@ fn preview_image( for l in solo.layers.iter_mut() { if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { l.visible = false; + } else { + // Its clipping base is hidden here, which would clip it away entirely (#5239). + l.clipped = false; } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { @@ -1187,11 +1209,26 @@ fn start_steps(app: &mut PhotocraftApp) { pub fn track_steps(app: &mut PhotocraftApp, ctx: &egui::Context) { let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return }; if pv.gesture.is_some() || pv.warp_drag.is_some() || ctx.input(|i| i.pointer.any_down()) || ctx.text_edit_focused() { + // Whatever comes to rest after this is more than the nudge. + pv.steps.fold = false; return; } pv.steps.settle(Step::of(t)); } +/// Arrow keys while Free Transform is active: move the box (and its pivot) by `(dx, dy)`. +/// Nudges that follow each other closely are one Undo step of the session (`nudge_bundle`). +pub(crate) fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) { + let (pause, now) = (crate::nudge_bundle::pause(app), app.nudge_bundle.now); + let Some(t) = app.ui.transform.as_mut().filter(|t| t.warp.is_none()) else { return }; + let before = Step::of(t); + t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]); + t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy]; + if let Some(pv) = app.transform_preview.as_mut() { + pv.steps.nudged(&before, Step::of(t), now, pause); + } +} + /// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { @@ -1354,7 +1391,6 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> warp_pointer(app, ev, tol, mods); return true; } - let no_distort = distort_refused(app, &t); let Some(pv) = app.transform_preview.as_mut() else { return false }; match ev { ToolEvent::Down { x, y, .. } => { @@ -1385,28 +1421,47 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> commit(app); return true; } - let legacy = app.session.prefs().general.use_legacy_free_transform; - let unlinked = app.ui.transform_unlinked; let type_layer = moves_type(app, &t); - if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) + let adjusted = g.map(|g| drag_mods(app, &t, g.hit, mods)); + if let (Some(g), Some((mode, mods)), Some(s)) = (g, adjusted, app.ui.transform.as_mut()) && mode_allows(t.mode, g.hit) && (t.content_aware.is_none() || g.hit != Hit::Outside) { - let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) }; if t.content_aware.is_some() { - let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); + let mut mods = mode_mods(mode, g.hit, mods); // Content-Aware Scale: ⌘ neither skews nor distorts. mods.command = false; apply_drag(s, g, [x, y], mods); } else { - apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); + apply_mode_drag(s, g, [x, y], mode, type_layer, mods); } } } } + crate::snap_ui::retain_transform_lines(app, mods); true } +/// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion +/// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. +fn drag_mods(app: &PhotocraftApp, t: &TransformSession, hit: Hit, mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) { + let (mode, mods) = if distort_refused(app, t) { scale_not_distort(t.mode, hit, mods) } else { (t.mode, mods) }; + let legacy = app.session.prefs().general.use_legacy_free_transform; + (mode, legacy_mods(legacy, hit, unlinked_mods(app.ui.transform_unlinked, legacy, hit, mods))) +} + +/// Only a freely distorted corner can snap its proposed position. Perspective moves paired +/// corners on one axis; Skew, Warp and Content-Aware Scale have their own constrained geometry. +pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Modifiers) -> bool { + let Some(t) = app.ui.transform.as_ref() else { return false }; + if t.quad.get(index).is_none() || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Skew { + return false; + } + let (mode, mods) = drag_mods(app, t, Hit::Corner(index), mods); + let mods = mode_mods(mode, Hit::Corner(index), mods); + mods.command && !(mods.alt && mods.shift) +} + /// Editable type can be scaled, rotated and skewed but not distorted (#2630): the rule that greys /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. @@ -1802,9 +1857,8 @@ fn surface_at(mesh: &BezierMesh, p: [f64; 2]) -> Option<(f64, f64)> { } /// Ctrl-click (or Option-click) picks the split: crosswise in the open, and the perpendicular -/// split when the pointer is close to an existing grid line. -fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTool { - let (s, t) = mesh.param_at(p); +/// split when the pointer's grid position `(s, t)` is close to an existing grid line. +fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], (s, t): (f64, f64)) -> SplitTool { let w = (bounds[2] - bounds[0]).abs().max(1.0); let h = (bounds[3] - bounds[1]).abs().max(1.0); let su = (8.0 / w).clamp(0.02, 0.12); @@ -1820,12 +1874,18 @@ fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTo } } -fn split_cursor(tool: SplitTool) -> CursorIcon { - match tool { - SplitTool::Vertical => CursorIcon::ResizeHorizontal, - SplitTool::Horizontal => CursorIcon::ResizeVertical, - SplitTool::Cross => CursorIcon::Crosshair, - } +/// The cursor for a split armed at grid position `(s, t)` of warp grid `m`: the arrow across the +/// split line as it looks on screen. A vertical split line runs along the grid's columns and is +/// placed by moving across them, in the grid's own direction there, so the arrow turns with a +/// turned or bent grid and with the view. +fn split_cursor(m: &BezierMesh, tool: SplitTool, (s, t): (f64, f64), xf: &ViewXform) -> CursorIcon { + let e = 1e-3; + let (a, b) = match tool { + SplitTool::Vertical => (m.eval((s - e).max(0.0), t), m.eval((s + e).min(1.0), t)), + SplitTool::Horizontal => (m.eval(s, (t - e).max(0.0)), m.eval(s, (t + e).min(1.0))), + SplitTool::Cross => return CursorIcon::Crosshair, + }; + crate::tool_cursor::resize([b[0] - a[0], b[1] - a[1]], xf) } /// Armed split tool, or Option held: the guide tracks the pointer and the split lands on release. @@ -1858,7 +1918,10 @@ fn split_gesture(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let kind = if let Some(tool) = armed { Some(tool) } else if quick { - app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).map(|w| quick_split_kind(&w.to_mesh(1, 1), w.bounds, [x, y])) + app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).map(|w| { + let m = w.to_mesh(1, 1); + quick_split_kind(&m, w.bounds, m.param_at([x, y])) + }) } else { None }; @@ -1997,20 +2060,22 @@ fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) } -/// Cursor for hovering a document point while transforming. `quick_split` is Ctrl or Option, -/// which arms a quick split while a warp is active. -pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option { +/// Cursor for hovering a document point while transforming. `xf` is the canvas's view; +/// `quick_split` is Ctrl or Option, which arms a quick split while a warp is active. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], quick_split: bool) -> Option { let t = app.ui.transform.as_ref()?; let tol = handle_tolerance(app); if let Some(w) = &t.warp { if warps_type(app) { return Some(CursorIcon::Default); } - if let Some(tool) = app.transform_preview.as_ref().and_then(|pv| pv.split_tool) { - return Some(split_cursor(tool)); - } - if quick_split { - return Some(split_cursor(quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); + let armed = app.transform_preview.as_ref().and_then(|pv| pv.split_tool); + if armed.is_some() || quick_split { + // One search for the grid position under the pointer, for both the kind and the arrow. + let m = w.to_mesh(1, 1); + let at = m.param_at(p); + let tool = armed.unwrap_or_else(|| quick_split_kind(&m, w.bounds, at)); + return Some(split_cursor(&m, tool, at, xf)); } if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { return Some(CursorIcon::Crosshair); @@ -2025,11 +2090,17 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option CursorIcon::ResizeNwSe, - Hit::Corner(_) => CursorIcon::ResizeNeSw, - Hit::Edge(0 | 2) => CursorIcon::ResizeVertical, - Hit::Edge(_) => CursorIcon::ResizeHorizontal, + Hit::Corner(i) => resize(corner(i + 2), corner(i)), + Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, Hit::Outside => CursorIcon::Alias, @@ -2045,19 +2116,14 @@ fn front_vertices(h: &Homography, n: usize) -> Vec { (0..=n).flat_map(|j| (0..=n).map(move |i| (i as f64 / n as f64, j as f64 / n as f64))).map(|(u, v)| front * h.w(u, v) > 1e-9).collect() } -/// Warped preview of the moving pixels plus the box, handles and reference point. -pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { +/// The moving pixels as the box shows them (warped, skewed or styled), plus the layers above +/// them. This is image content: the canvas draws it right after the document, under the grid +/// and guides, so a guide stays visible across a layer being transformed (#5086), as in +/// Photoshop. The box, handles and reference point are [`draw_overlay`], drawn over the guides. +pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; - let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); if let Some(w) = &t.warp { - let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); - let at = pv.split_pointer.or(over_canvas); - let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); - let guide = at.filter(|_| !warps_type(app)).and_then(|p| { - let tool = pv.split_tool.or_else(|| (alt || pv.split_quick).then(|| quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); - tool.map(|tool| (tool, p)) - }); - draw_warp(painter, xf, t, w, pv, guide); + draw_warp_content(painter, xf, t, w, pv); return; } let ctx = painter.ctx(); @@ -2105,6 +2171,29 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(a) = &pv.above { a.draw(painter, xf); } +} + +/// The box, handles, reference point and path outline over the preview that [`draw_content`] +/// drew; the warp grid and its split guides for a warp. +pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; + let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + if let Some(w) = &t.warp { + let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); + let at = pv.split_pointer.or(over_canvas); + let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); + let guide = at.filter(|_| !warps_type(app)).and_then(|p| { + let tool = pv.split_tool.or_else(|| { + (alt || pv.split_quick).then(|| { + let m = w.to_mesh(1, 1); + quick_split_kind(&m, w.bounds, m.param_at(p)) + }) + }); + tool.map(|tool| (tool, p)) + }); + draw_warp_overlay(painter, xf, w, pv, guide); + return; + } if pv.committing.is_some() { return; } @@ -2150,6 +2239,72 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// Applying the open box moves the selection outline with it: `edit.transform` and +/// `edit.transform.warp` map it like the pixels. Not Transform Selection (its ants are hidden), a +/// path, Content-Aware Scale or a type layer's Warp Text, which leave the selection alone. +fn carries_selection(app: &PhotocraftApp, t: &TransformSession) -> bool { + !t.selection && t.path.is_none() && t.content_aware.is_none() && !(t.warp.is_some() && warps_type(app)) +} + +/// A transform of `document` is open that carries its selection along (#5297): the canvas traces +/// the whole outline, not only the part in view where it started, for [`draw_selection`]. +pub(crate) fn carries_selection_of(app: &PhotocraftApp, document: DocId) -> bool { + matches!((&app.ui.transform, &app.transform_preview), (Some(t), Some(pv)) if pv.doc.id == document && carries_selection(app, t)) +} + +/// The marching ants where the open box puts them (#5297): the selection follows a move, scale, +/// rotation, skew, distort, perspective or warp while it is made, to where applying it leaves it. +/// `segs` is the outline of `document`'s selection at rest. False when no open transform carries +/// that selection: the caller draws it where it is. +pub(crate) fn draw_selection( + app: &PhotocraftApp, + painter: &egui::Painter, + xf: &ViewXform, + document: DocId, + segs: &[crate::outline::Segment], + time: f64, +) -> bool { + let Some(t) = app.ui.transform.as_ref().filter(|_| carries_selection_of(app, document)) else { return false }; + let scr = |(x, y): (f64, f64)| xf.to_screen(x as f32, y as f32); + let doc = |p: [i32; 2]| [f64::from(p[0]), f64::from(p[1])]; + let along = |a: [f64; 2], b: [f64; 2], k: f64| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; + if let Some(w) = &t.warp { + // A warp bends the outline's straight runs: each is drawn in pieces about 8 points long. + let piece = (8.0 / f64::from(xf.zoom.max(1e-3))).max(1.0); + let lines = segs.iter().flat_map(|(a, b)| { + let (a, b) = (doc(*a), doc(*b)); + let n = (((b[0] - a[0]).abs() + (b[1] - a[1]).abs()) / piece).ceil().clamp(1.0, 256.0) as usize; + let at = move |i: usize| { + let p = along(a, b, i as f64 / n as f64); + scr(w.map(p[0], p[1])) + }; + (0..n).map(move |i| (at(i), at(i + 1))) + }); + crate::canvas::marching_ants_lines(painter, lines, time); + } else if let Some(h) = Homography::rect_to_quad(t.rect, t.quad) { + // Past a Distort's horizon the outline has no place, like the pixels (#4863): a run that + // crosses it stops short of it, where its points are still a finite way off. + let front = h.front_side(t.rect); + let depth = |p: [f64; 2]| front * h.w(p[0], p[1]); + let near = corners(t.rect).iter().map(|c| depth(*c)).fold(0.0, f64::max) * 1e-3; + let lines = segs.iter().filter_map(|(a, b)| { + let (mut a, mut b) = (doc(*a), doc(*b)); + let (da, db) = (depth(a), depth(b)); + if da < near && db < near { + return None; + } + if da < near { + a = along(a, b, (near - da) / (db - da)); + } else if db < near { + b = along(b, a, (near - db) / (da - db)); + } + Some((scr(h.apply(a[0], a[1])), scr(h.apply(b[0], b[1])))) + }); + crate::canvas::marching_ants_lines(painter, lines, time); + } + true +} + /// What the styled preview of the moving layer is drawn from (transform_fx.rs), at `zoom` screen /// pixels per document pixel. `None` for a layer without a style (also once it is switched off /// while the box is open) or a hidden one: the bare pixels are drawn instead. The style, opacity @@ -2335,8 +2490,8 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; } } -fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { - let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); +/// The warped pixels and the layers above them ([`draw_content`] for a warp). +fn draw_warp_content(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview) { let r = t.rect; let m = w.to_mesh(1, 1); let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64); @@ -2356,6 +2511,12 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: & if let Some(a) = &pv.above { a.draw(painter, xf); } +} + +/// The warp grid, its control points and split guides ([`draw_overlay`] for a warp). +fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { + let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + let m = w.to_mesh(1, 1); let accent = crate::theme::Tokens::get(painter.ctx()).accent; // Transform Path › Warp: the path drawn through the warp. if let Some(path) = &pv.path { @@ -3973,6 +4134,123 @@ mod tests { assert_eq!((t.layer, t.rect), (second.0, [40.0, 40.0, 56.0, 60.0]), "the next box is on the selected layer"); } + /// The screen rectangle the marching ants cover as [`draw_selection`] paints them over the + /// active document's selection; `None` when it leaves them to the canvas. + fn ants_bounds(app: &PhotocraftApp, xf: &ViewXform) -> Option { + let st = app.session.active().unwrap(); + let sel = st.doc.selection.as_ref().unwrap(); + let segs = crate::outline::outline(sel, sel.content_bounds()); + let ctx = egui::Context::default(); + let mut drawn = false; + let mut output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("ants-test"))); + drawn = draw_selection(app, &painter, xf, st.doc.id, &segs, 0.0); + }); + output.textures_delta.clear(); + // The ants are a mesh of straight dashes and line shapes for the slanted ones. + let ants = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. })); + drawn.then(|| ants.map(|s| s.shape.visual_bounding_rect()).fold(egui::Rect::NOTHING, egui::Rect::union)) + } + + /// The screen rectangle around document points. + fn screen_bounds(xf: &ViewXform, points: &[[f64; 2]]) -> egui::Rect { + egui::Rect::from_points(&points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect::>()) + } + + fn rects_match(a: egui::Rect, b: egui::Rect) -> bool { + (a.min - b.min).abs().max_elem() <= 2.0 && (a.max - b.max).abs().max_elem() <= 2.0 + } + + /// #5297: with a selection, Free Transform's box is the selection's own rectangle (wider here + /// than the pixels it holds), and the marching ants stay on the box through a move, a scale, a + /// rotation and a distort, before anything is applied. Applying leaves them there. + #[test] + fn the_box_is_the_selection_and_the_ants_follow_it() { + let ctx = gpu_ctx(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), + zoom: 2.0, + center: [32.0, 32.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + app.run("select.rect", json!({"x": 4, "y": 4, "width": 30, "height": 26})).unwrap(); + assert_eq!(ants_bounds(&app, &xf), None, "no box open: the canvas draws them where they are"); + begin(&mut app, &ctx).unwrap(); + let rest = corners([4.0, 4.0, 34.0, 30.0]); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, rest, "the box is the selection, not the 16 px square in it"); + assert!(rects_match(ants_bounds(&app, &xf).unwrap(), screen_bounds(&xf, &rest)), "untouched, the ants are where they were"); + + let on_the_box = |app: &PhotocraftApp, what: &str| { + let quad = app.ui.transform.as_ref().unwrap().quad; + let (ants, boxed) = (ants_bounds(app, &xf).unwrap(), screen_bounds(&xf, &quad)); + assert!(rects_match(ants, boxed), "{what}: ants {ants:?}, box {boxed:?}"); + }; + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y + 10.0]); + t.pivot = [t.pivot[0] + 20.0, t.pivot[1] + 10.0]; + } + on_the_box(&app, "moved"); + let moved = app.ui.transform.as_ref().unwrap().quad; + scale_about_pivot(&mut app, 0.5, 1.5); + on_the_box(&app, "scaled"); + rotate_about_pivot(&mut app, 0.6); + on_the_box(&app, "rotated"); + if let Some(t) = app.ui.transform.as_mut() { + t.quad[2] = [t.quad[2][0] + 9.0, t.quad[2][1] + 13.0]; + } + on_the_box(&app, "distorted"); + + // Applied as moved only: the outline is where the ants showed it. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = moved; + } + commit(&mut app); + assert!(app.ui.transform.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(24, 14, 54, 40)); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 18, 44, 34)); + } + + /// #5297: in Warp mode the ants bend with the mesh, and past a Distort's horizon they stop + /// rather than run off to infinity. + #[test] + fn the_ants_follow_a_warp_and_stop_at_a_distorts_horizon() { + let ctx = gpu_ctx(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 56, 56)); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), + zoom: 2.0, + center: [32.0, 32.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + app.run("select.rect", json!({"x": 12, "y": 12, "width": 40, "height": 40})).unwrap(); + begin(&mut app, &ctx).unwrap(); + let rest = corners([12.0, 12.0, 52.0, 52.0]); + + // Two corners dragged across each other put half the box past the horizon. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = [[12.0, 12.0], [52.0, 12.0], [20.0, 40.0], [44.0, 40.0]]; + } + let ants = ants_bounds(&app, &xf).unwrap(); + assert!(ants == egui::Rect::NOTHING || (ants.is_finite() && ants.width() < 1e5 && ants.height() < 1e5), "{ants:?}"); + + if let Some(t) = app.ui.transform.as_mut() { + t.quad = rest; + } + enter_warp(&mut app, &ctx); + let arc = Warp::preset(WarpStyle::Arc, 50.0, [12.0, 12.0, 52.0, 52.0]); + app.ui.transform.as_mut().unwrap().warp = Some(arc.clone()); + let ants = ants_bounds(&app, &xf).unwrap(); + assert!(!rects_match(ants, screen_bounds(&xf, &rest)), "the outline left its place: {ants:?}"); + let warped: Vec<[f64; 2]> = rest.iter().map(|p| arc.map(p[0], p[1])).map(|(x, y)| [x, y]).collect(); + assert!(ants.expand(2.0).contains_rect(screen_bounds(&xf, &warped)), "the warped corners are on it: {ants:?}"); + } + /// Transform Selection moves the selection outline, not a layer: selecting a layer keeps it open. #[test] fn selecting_another_layer_keeps_a_transform_selection_open() { @@ -4083,15 +4361,69 @@ mod tests { let tol = handle_tolerance(&app); let h = std::f64::consts::FRAC_1_SQRT_2; let (tl, br) = ([32.0 - tol * 1.5, 32.0 - tol * 1.5], [160.0 + tol * 1.5, 160.0 + tol * 1.5]); - assert_eq!(cursor(&app, tl, false), Some(CursorIcon::None), "the arrow is drawn instead"); + let xf = ViewXform::active(&app).unwrap(); + assert_eq!(cursor(&app, &xf, tl, false), 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, [60.0, 60.0], false), Some(CursorIcon::Move)); + assert_eq!(cursor(&app, &xf, [60.0, 60.0], false), Some(CursorIcon::Move)); 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); } + /// The resize arrows follow the box's turn and the view's rotation and flip: each handle + /// shows the arrow it resizes along on screen, not the one it had on the upright box. + #[test] + fn resize_cursors_turn_with_the_box_and_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + // Box turned (degrees), view rotation, flip → corner 0, corner 1, edge 0, edge 1 (corner + // and edge i + 2 sit opposite, on the same axis). + let cases = [ + (0.0, 0.0, false, [NwSe, NeSw, V, H]), + (90.0, 0.0, false, [NeSw, NwSe, H, V]), + (45.0, 0.0, false, [V, H, NeSw, NwSe]), + (0.0, 0.0, true, [NeSw, NwSe, V, H]), + (0.0, 90.0, false, [NeSw, NwSe, H, V]), + ]; + for (deg, view, flip, want) in cases { + let mut app = app_with_square(256, photocraft_geom::Rect::new(64, 96, 192, 160)); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let view_index = app.session.active_index().unwrap(); + app.ui.views[view_index].rotation = view; + app.ui.view.flip_horizontal = flip; + let t = app.ui.transform.as_mut().unwrap(); + let (s, c) = f64::to_radians(deg).sin_cos(); + let o = [128.0, 128.0]; + t.quad = t.quad.map(|[x, y]| [o[0] + (x - o[0]) * c - (y - o[1]) * s, o[1] + (x - o[0]) * s + (y - o[1]) * c]); + let t = app.ui.transform.clone().unwrap(); + let xf = ViewXform::active(&app).unwrap(); + let mid = |i: usize| [(t.quad[i][0] + t.quad[(i + 1) % 4][0]) / 2.0, (t.quad[i][1] + t.quad[(i + 1) % 4][1]) / 2.0]; + let got: Vec<_> = (0..4).map(|i| cursor(&app, &xf, t.quad[i], false)).chain((0..4).map(|i| cursor(&app, &xf, mid(i), false))).collect(); + let want = [want[0], want[1], want[0], want[1], want[2], want[3], want[2], want[3]].map(Some); + assert_eq!(got, want, "box {deg}°, view {view}°, flip {flip}"); + } + } + + /// A warp split's arrow crosses the split line as it looks on screen: it turns with a turned + /// grid (Warp entered on a rotated box) and with a rotated view. + #[test] + fn warp_split_cursors_turn_with_the_grid_and_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let rect = [0.0, 0.0, 200.0, 100.0]; + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let level = ViewXform { rect: screen, zoom: 1.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // Grid turned (degrees, about its centre), view rotation → Split Vertically, Split Horizontally. + for (grid, view, want) in [(0.0, 0.0, [H, V]), (0.0, 90.0, [V, H]), (90.0, 0.0, [V, H]), (45.0, 0.0, [NwSe, NeSw])] { + let (s, c) = f64::to_radians(grid).sin_cos(); + let m = BezierMesh::identity(rect, 1, 1).map_points(|[x, y]| [100.0 + (x - 100.0) * c - (y - 50.0) * s, 50.0 + (x - 100.0) * s + (y - 50.0) * c]); + let xf = ViewXform { rotation: view, ..level }; + let got = [SplitTool::Vertical, SplitTool::Horizontal].map(|tool| split_cursor(&m, tool, m.param_at([100.0, 50.0]), &xf)); + assert_eq!(got, want, "grid turned {grid}°, view {view}°"); + } + let m = BezierMesh::identity(rect, 1, 1); + assert_eq!(split_cursor(&m, SplitTool::Cross, (0.5, 0.5), &level), CursorIcon::Crosshair); + } + /// #1767: the readout beside the pointer during a drag (Photoshop's transformation values): /// the angle while rotating, W/H while scaling, ΔX/ΔY while moving; nothing otherwise. #[test] @@ -4387,12 +4719,12 @@ mod tests { assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]); } - /// The textured meshes `draw_overlay` paints for the moving pixels (not the box or handles). + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); - draw_overlay(app, &painter, xf); + draw_content(app, &painter, xf); }); out.textures_delta.clear(); out.shapes @@ -4520,6 +4852,7 @@ mod tests { rotation: 0.0, aspect: 1.0, }; + draw_content(&app, &painter, &xf); draw_overlay(&app, &painter, &xf); }); t0.elapsed().as_secs_f64() * 1e3 @@ -4794,6 +5127,27 @@ mod tests { } } + /// #5239: a smart object clipped to the layer under it previewed as nothing while the box + /// was open (its flattened render hid the clipping base too). + #[test] + fn preview_of_a_clipped_smart_object_is_not_empty() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.session.execute("layer.createClippingMask", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + assert!(st.doc.layer(id).unwrap().clipped); + let (img, _) = preview_image(&st.doc, id, None, photocraft_geom::Rect::new(0, 0, 64, 64), 4096, false); + assert_eq!(texel_alpha(&img, 32, 32), 255, "the moving object shows"); + } + #[test] fn targeted_unlinked_mask_transforms_alone_through_the_box() { let mut app = masked_app(false); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index dcbbb70b3c..0ed51de669 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + guide: false, spelling: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); diff --git a/crates/ui-egui/src/transform_undo_tests.rs b/crates/ui-egui/src/transform_undo_tests.rs index 832b221900..b863eb8fff 100644 --- a/crates/ui-egui/src/transform_undo_tests.rs +++ b/crates/ui-egui/src/transform_undo_tests.rs @@ -343,3 +343,46 @@ fn the_first_drag_from_the_move_tools_controls_undoes() { key(&mut h, Modifiers::COMMAND, Key::Z); assert!(close(quad(&h), [[100.0, 100.0], [200.0, 100.0], [200.0, 160.0], [100.0, 160.0]]), "{:?}", quad(&h)); } + +/// #3402's layer-switch case (#5297): selected pixels moved with the Move tool, then moved again +/// in the Free Transform box, land where the box left them when another layer is picked. +#[test] +fn picking_another_layer_applies_the_transform_of_moved_selected_pixels() { + let mut h = harness(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + drag(&mut h, [150.0, 130.0], [250.0, 180.0]); + begin(&mut h); + // Away from the centre, where a press would take the reference point instead. + drag(&mut h, [220.0, 165.0], [240.0, 175.0]); + assert_eq!(quad(&h), [[220.0, 160.0], [320.0, 160.0], [320.0, 220.0], [220.0, 220.0]]); + let other = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|id| *id != layer).unwrap(); + h.state_mut().run("layer.select", json!({"layer": other.0})).unwrap(); + crate::transform_tool::end_if_left(h.state_mut()); + h.run_steps(3); + assert!(h.state().ui.transform.is_none(), "picking another layer applies the transform"); + let st = h.state().session.active().unwrap(); + let landed = photocraft_geom::Rect::new(220, 160, 320, 220); + let (pixels, selection) = (st.doc.layer(layer).unwrap().surface().unwrap().content_bounds(), st.doc.selection.as_ref().map(|s| s.content_bounds())); + assert_eq!((pixels, selection), (landed, Some(landed))); +} + +/// #5297 through the real canvas: ⌘T with a selection wider than the layer opens the box on the +/// selection, and dragging it moves the selected pixels and the selection together. +#[test] +fn free_transform_opens_on_the_selection_and_moves_it_with_the_pixels() { + let mut h = harness(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + // The left 60 px of the red rectangle, and empty layer to its left, above and below. + h.state_mut().run("select.rect", json!({"x": 60, "y": 80, "width": 100, "height": 120})).unwrap(); + begin(&mut h); + assert_eq!(quad(&h), [[60.0, 80.0], [160.0, 80.0], [160.0, 200.0], [60.0, 200.0]]); + drag(&mut h, [80.0, 100.0], [130.0, 130.0]); + assert_eq!(quad(&h), [[110.0, 110.0], [210.0, 110.0], [210.0, 230.0], [110.0, 230.0]]); + key(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().ui.transform.is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(110, 110, 210, 230)); + // What stayed (160..200 × 100..160) and what moved (to 150..210 × 130..190). + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(150, 100, 210, 190)); +} diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index f17032523d..176e683a9d 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -490,3 +490,43 @@ fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(1, 3, 0.0), None); assert_eq!(super::wheel_family_index(0, 0, -1.0), None); } + +/// #5152: in the full app the Properties panel's Character section draws a second font picker +/// after the options bar; hovering a font in the options bar still previews it on the canvas. +#[test] +fn options_bar_hover_previews_with_the_character_picker_also_shown() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + let theme = crate::theme::ThemeKind::Pro; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.ui.theme = theme; + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + app.sync_views(); + app + }); + h.run_steps(8); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let font = |h: &mut Harness<'static, PhotocraftApp>| { + let (shown, _) = crate::canvas::display_doc(h.state_mut(), 0); + let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; + t.char_runs()[0].style.font_family.clone() + }; + let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); + pickers[0].click(); + h.run(); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").type_text("JetBrains Mono"); + h.run(); + h.get_by_label("JetBrains Mono").hover(); + h.run(); + assert_eq!(font(&mut h), "JetBrains Mono"); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").hover(); + h.run(); + assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..bee49293b5 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1553,7 +1553,9 @@ pub(crate) fn family_picker_in( let hover_id = egui::Id::new("type-font-hover"); if let Some(f) = hovered_font { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else { + } else if egui::ComboBox::is_open(ui.ctx(), response.response.id) { + // Only the open menu clears it: the options bar and the Character section both draw + // this picker, and the closed one must not wipe the other's hover (#5152). ui.ctx().data_mut(|d| d.remove::(hover_id)); } } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 2a68366d6e..b724cfe321 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -349,16 +349,6 @@ pub(crate) fn draw_box(painter: &egui::Painter, xf: &ViewXform, t: &TransformSes } } -pub(crate) fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { - let angle = d[1].atan2(if flip { -d[0] } else { d[0] }).rem_euclid(std::f64::consts::PI); - match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { - 0 => CursorIcon::ResizeHorizontal, - 1 => CursorIcon::ResizeNwSe, - 2 => CursorIcon::ResizeVertical, - _ => CursorIcon::ResizeNeSw, - } -} - pub(crate) fn rotation_cursor(ctx: &egui::Context, p: Pos2) { let tokens = crate::theme::Tokens::get(ctx); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-rotate-cursor"))); @@ -399,7 +389,7 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] }; [b[0] - t.quad[0][0], b[1] - t.quad[0][1]] }; - return Some(resize_cursor(direction, xf.flip)); + return Some(crate::tool_cursor::resize(direction, xf)); } let Some(t) = frame(app) else { return Some(CursorIcon::Default) }; let h = app.ui.type_transform.as_ref().map(|t| t.gesture.hit).or_else(|| hit(app, &t, xf.to_doc(p))); @@ -407,7 +397,7 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor Some(Hit::Corner(i)) => { let q = *t.quad.get(i)?; let opposite = *t.quad.get((i + 2) % 4)?; - resize_cursor([q[0] - opposite[0], q[1] - opposite[1]], xf.flip) + crate::tool_cursor::resize([q[0] - opposite[0], q[1] - opposite[1]], xf) } Some(Hit::Edge(_)) => { // Original arrowhead indicator, above the canvas, without a proprietary cursor asset. diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e9d998b046..f68a4a6a4e 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -206,8 +206,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } /// 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). -pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { +/// 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. +pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) @@ -234,18 +235,25 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen.adjusting_last = true; return; } - pen.knots.push([[x, y]; 3]); + let p = match pen.knots.last() { + Some(prev) => shift_45(prev[0], [x, y], shift), + None => [x, y], + }; + pen.knots.push([p; 3]); pen.dragging = true; pen.adjusting_last = false; } -pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64) { +/// Pen drag: pull out the handles of the anchor just placed. ⇧ (read live, as it is held) +/// turns the handle to the nearest 45° direction from its anchor. +pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { let adjusting_last = pen.adjusting_last; if let Some(k) = pen.knots.last_mut() { let a = k[0]; + let [x, y] = shift_45(a, [x, y], shift); k[2] = [x, y]; if !adjusting_last { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; @@ -254,6 +262,11 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64) { } } +/// `p`, or with ⇧ `p` projected onto the nearest 45° ray from `origin` (Pen tool constraint). +fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { + if shift { crate::stroke_constraint::snap45(origin, p) } else { p } +} + /// Undo the most recently placed Pen anchor while its path is still in progress. /// These knots are editor-only gesture state, not document history until Enter or /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one @@ -545,6 +558,11 @@ fn curvature_refresh(pen: &mut PenPath, closed: bool) { pen.knots = photocraft_vector::edit::curvature_knots(&anchors, &corners, closed); } +/// The anchor of the Curvature Pen path being drawn under (x, y), for the cursor. +pub(crate) fn curvature_anchor_at(app: &PhotocraftApp, x: f64, y: f64) -> Option { + curvature_hit(app, x, y) +} + /// The Curvature Pen's anchor within a few screen pixels of (x, y). fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { let tol = 6.0 / app.point_zoom().max(0.01) as f64; @@ -593,8 +611,14 @@ pub fn curvature_up(app: &mut PhotocraftApp) { } /// Curvature Pen double-click: toggle the anchor under the pointer between smooth and corner -/// (double-clicking empty canvas therefore places a corner point, as in Photoshop). +/// (double-clicking empty canvas therefore places a corner point, as in Photoshop). With no path +/// being drawn, an anchor of the active path toggles the same way and its subpath reflows +/// (`path.convertPoint` with `curvature`), so a Pen-drawn corner rounds off. pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) { + crate::direct_select::curvature_toggle_active(app, [x, y]); + return; + } let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { @@ -931,7 +955,6 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { @@ -952,16 +975,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - if vector_tool { - if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); - } - let st = app.session.active(); - if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) - && let LayerContent::Shape(sh) = &l.content - { - draw_path(&sh.path, tool == Tool::PathSelection); - } + // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct + // Selection has its own overlay above. + if let Some(wp) = &doc.work_path { + draw_path(wp, tool == Tool::PathSelection); + } + let st = app.session.active(); + if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) + && let LayerContent::Shape(sh) = &l.content + { + draw_path(&sh.path, tool == Tool::PathSelection); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { @@ -969,6 +992,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. @@ -1002,6 +1026,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); } } else { + // The rubber band ends where a ⇧-click would put the next anchor. + let shift = painter.ctx().input(|i| i.modifiers.shift); + let h = if pen.curvature { h } else { shift_45(last[0], h, shift) }; painter.add(egui::Shape::dashed_line(&[to_scr(last[0]), to_scr(h)], Stroke::new(1.0, accent), 4.0, 3.0)); } } @@ -2076,11 +2103,11 @@ mod tests { fn pen_shows_the_close_cue_only_over_the_first_anchor_of_a_real_segment() { let mut app = app(); app.ui.tool = Tool::Pen; - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); let one = app.ui.pen.clone().unwrap(); assert!(!over_first_anchor(&one, [20.0, 20.0], 6.0), "a single anchor has nothing to close"); - pen_down(&mut app, 120.0, 20.0); + pen_down(&mut app, 120.0, 20.0, false); pen_up(&mut app); let two = app.ui.pen.clone().unwrap(); assert!(over_first_anchor(&two, [22.0, 21.0], 6.0)); @@ -2092,13 +2119,13 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 20.0)] { - pen_down(&mut app, x, y); + pen_down(&mut app, x, y, false); pen_up(&mut app); } - pen_down(&mut app, 120.0, 120.0); - pen_move(&mut app, 140.0, 140.0); // smooth knot + pen_down(&mut app, 120.0, 120.0, false); + pen_move(&mut app, 140.0, 140.0, false); // smooth knot pen_up(&mut app); - pen_down(&mut app, 20.5, 20.5); // click the first anchor: close + pen_down(&mut app, 20.5, 20.5, false); // click the first anchor: close pen_up(&mut app); assert!(app.ui.pen.is_none()); let wp = app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2110,6 +2137,75 @@ mod tests { assert_eq!((wp.subpaths[0].knots[0].anchor.x, wp.subpaths[0].knots[0].anchor.y), (30.0, 25.0)); } + fn close(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9 + } + + #[test] + fn pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray() { + let mut app = app(); + app.ui.tool = Tool::Pen; + // The first anchor has nothing to constrain against: it lands where clicked. + pen_down(&mut app, 20.0, 20.0, true); + pen_up(&mut app); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[0][0], [20.0, 20.0]); + // ~30° below horizontal snaps to the 45° diagonal at the pointer's projection. + let (dx, dy) = (100.0 * 30f64.to_radians().cos(), 100.0 * 30f64.to_radians().sin()); + pen_down(&mut app, 20.0 + dx, 20.0 + dy, true); + pen_up(&mut app); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + let t = 100.0 * 15f64.to_radians().cos() / std::f64::consts::SQRT_2; + assert!(close(k[0], [20.0 + t, 20.0 + t]), "{:?}", k[0]); + assert!(k[1] == k[0] && k[2] == k[0], "a click stays a corner"); + // ~10° off vertical snaps to straight down from the previous anchor. + let prev = k[0]; + pen_down(&mut app, prev[0] + 8.0, prev[1] + 50.0, true); + pen_up(&mut app); + assert!(close(app.ui.pen.as_ref().unwrap().knots[2][0], [prev[0], prev[1] + 50.0])); + // Without ⇧ the anchor stays at the pointer. + pen_down(&mut app, 200.0, 190.0, false); + pen_up(&mut app); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[3][0], [200.0, 190.0]); + // ⇧ does not stop a click on the first anchor from closing the path. + pen_down(&mut app, 20.5, 20.5, true); + pen_up(&mut app); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths[0].closed); + } + + #[test] + fn pen_shift_drag_turns_handles_to_45_degrees_live() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 100.0, 100.0, false); + // Without ⇧ the handle follows the pointer. + pen_move(&mut app, 130.0, 110.0, false); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[1][2], [130.0, 110.0]); + // Pressing ⇧ mid-drag snaps the handle (~18°) to horizontal; the opposite handle mirrors it. + pen_move(&mut app, 130.0, 110.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + assert!(close(k[2], [130.0, 100.0]) && close(k[1], [70.0, 100.0]), "{k:?}"); + assert_eq!(k[0], [100.0, 100.0], "the anchor does not move"); + // Releasing ⇧ frees it again. + pen_move(&mut app, 120.0, 140.0, false); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[1][2], [120.0, 140.0]); + // ⌥ breaks the pair; ⇧ then constrains the outgoing handle alone (~63° → 45°). + pen_break_handle(&mut app); + pen_move(&mut app, 120.0, 140.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + assert!(close(k[2], [130.0, 130.0]), "{k:?}"); + assert_eq!(k[1], [80.0, 60.0], "the incoming handle stays where it was"); + pen_up(&mut app); + // Degenerate: ⇧ with the pointer on the anchor leaves the handle on it. + pen_down(&mut app, 200.0, 100.0, false); + pen_move(&mut app, 200.0, 100.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[2]; + assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); + pen_up(&mut app); + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. @@ -2119,10 +2215,10 @@ mod tests { app.ui.tool = Tool::Pen; let draw = |app: &mut PhotocraftApp, x: f64| { for (dx, dy) in [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0)] { - pen_down(app, x + dx, 20.0 + dy); + pen_down(app, x + dx, 20.0 + dy, false); pen_up(app); } - pen_down(app, x + 0.5, 20.5); // close + pen_down(app, x + 0.5, 20.5, false); // close pen_up(app); }; let work = |app: &PhotocraftApp| app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2163,16 +2259,16 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "no path yet"); - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "one anchor: a click there doesn't close"); - pen_down(&mut app, 120.0, 20.0); + pen_down(&mut app, 120.0, 20.0, false); assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "not while dragging the new anchor"); pen_up(&mut app); assert!(closes_path_at(&app, Tool::Pen, 21.0, 22.0), "over the first anchor"); assert!(!closes_path_at(&app, Tool::Pen, 120.0, 20.0) && !closes_path_at(&app, Tool::Pen, 60.0, 60.0), "the last anchor and empty canvas"); assert!(!closes_path_at(&app, Tool::CurvaturePen, 20.0, 20.0), "a Pen path isn't the Curvature Pen's"); - pen_down(&mut app, 21.0, 22.0); + pen_down(&mut app, 21.0, 22.0, false); pen_up(&mut app); assert!(app.ui.pen.is_none() && !closes_path_at(&app, Tool::Pen, 20.0, 20.0), "the click closed it"); @@ -2195,12 +2291,12 @@ mod tests { app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap(); let old_history = app.session.active().unwrap().history.past_len(); - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); - pen_down(&mut app, 120.0, 20.0); - pen_move(&mut app, 120.0, 60.0); + pen_down(&mut app, 120.0, 20.0, false); + pen_move(&mut app, 120.0, 60.0, false); pen_up(&mut app); - pen_down(&mut app, 120.0, 120.0); + pen_down(&mut app, 120.0, 120.0, false); pen_up(&mut app); let original_first_two = app.ui.pen.as_ref().unwrap().knots[..2].to_vec(); @@ -2221,7 +2317,7 @@ mod tests { fn pen_undo_of_first_anchor_cancels_empty_draft() { let mut app = app(); assert!(!pen_undo_last_point(&mut app)); - pen_down(&mut app, 15.0, 17.0); + pen_down(&mut app, 15.0, 17.0, false); pen_up(&mut app); assert!(pen_undo_last_point(&mut app)); assert!(app.ui.pen.is_none()); @@ -2243,10 +2339,10 @@ mod tests { // Drawing a work path with the Pen selects it. app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 20.0), (120.0, 120.0)] { - pen_down(&mut app, x, y); + pen_down(&mut app, x, y, false); pen_up(&mut app); } - pen_down(&mut app, 20.5, 20.5); + pen_down(&mut app, 20.5, 20.5, false); pen_up(&mut app); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); let wp = app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2336,13 +2432,13 @@ mod tests { for zoom in [0.25, 1.0, 4.0] { let mut app = app(); app.ui.views[0].zoom = zoom; - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); - pen_down(&mut app, 100.0, 100.0); - pen_move(&mut app, 110.0, 120.0); + pen_down(&mut app, 100.0, 100.0, false); + pen_move(&mut app, 110.0, 120.0, false); pen_up(&mut app); let incoming = app.ui.pen.as_ref().unwrap().knots[1][1]; - pen_down(&mut app, 100.0 + 5.0 / f64::from(zoom), 100.0); + pen_down(&mut app, 100.0 + 5.0 / f64::from(zoom), 100.0, false); pen_up(&mut app); let pen = app.ui.pen.as_ref().unwrap(); assert_eq!(pen.knots.len(), 2); @@ -2352,7 +2448,7 @@ mod tests { let restored: PenPath = serde_json::from_value(saved).unwrap(); assert_eq!(restored, *pen); assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false); - pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0); + pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0, false); assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3); } let legacy: PenPath = serde_json::from_value(json!({"knots": [[[10, 10], [10, 10], [10, 10]]]})).unwrap(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 793257be8b..8acd676f95 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -612,7 +612,7 @@ fn no_params(p: &Value) -> bool { /// `id` isn't ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if wraps(id) { - return front(app, id, params); + return front(app, ctx, id, params); } if !handles(id) { return None; @@ -985,6 +985,11 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi app.ui.open_dialog(DialogKind::Command, f) } +/// The Batch and Image Processor dialogs' "Include All Subfolders" checkbox reads as Photoshop's. +fn subfolders_label() -> Value { + json!({"includeSubfolders": tl!("Include All Subfolders")}) +} + /// Alt+Cancel (Reset): restore the `__form` params captured at open. pub fn reset_form(f: &mut Map) { if !f.contains_key("__form") { @@ -1154,7 +1159,7 @@ fn default_dir(app: &PhotocraftApp) -> String { app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into()) } -fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { +fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if id == "file.closeAll" || id == "file.closeOthers" { // Keep the remaining document's view (views are index-aligned with documents). let keep = app.session.active_index().and_then(|i| app.ui.views.get(i).cloned()); @@ -1192,15 +1197,24 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { + if app.ui.transform.is_some() { + return Some(Err("Commit or cancel the transform first".into())); + } let doc = match app.active_doc_id() { Ok(doc) => doc, Err(e) => return Some(Err(e)), }; let linked = id == "file.placeLinked"; + let ctx = ctx.clone(); Some(app.pick_file_bytes(move |app, name, bytes| { + if app.ui.transform.is_some() { + return Err("Commit or cancel the transform first".into()); + } app.refocus(doc)?; let linked = linked.then(|| name.clone()); - app.place_bytes(&name, bytes, linked) + let result = app.place_bytes(&name, bytes, linked)?; + crate::transform_tool::begin_placed(app, &ctx)?; + Ok(result) })) } "layer.smartObjects.replaceContents" => { @@ -1313,7 +1327,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog( app, - json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), + json!({"input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), json!({"format": ["jpg", "png", "psd", "tiff", "webp"]}), ), "file.automate.batch" => { @@ -1324,7 +1338,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Option { let f = NOTCH.powf(dy / notch); (f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f)) }; + let snap = |dy: f32| { + if dy == 0.0 { + return None; + } + let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 }; + let n = dy / notch; + if !n.is_finite() { + return None; + } + let steps = n.clamp(-10.0, 10.0).round() as i32; + if steps == 0 { None } else { Some(Wheel::SnapZoom(steps)) } + }; match (i.alt, i.zoom_with_wheel) { - // egui folds a scroll with Alt held into y. The smoothed tail of a notch is ignored. + // ⇧ + ⌥ + scroll: snap to the zoom ladder instead of continuous ×1.1. + (true, false) if i.shift => snap(i.raw.y + i.raw.x), + // ⌥ + scroll: continuous ×1.1 zoom. The smoothed tail of a notch is ignored. (true, false) => step(i.raw.y + i.raw.x), - // ⇧ and ⌘/Ctrl fold the wheel into x: those still scroll sideways. + // ⇧ + scroll wheel with the preference on: snap to the zoom ladder. + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + (false, true) if i.shift => snap(i.raw.y + i.raw.x), + // ⌘/Ctrl folds the wheel into x: those still scroll sideways. (false, true) if i.raw.y != 0.0 || i.scroll.y != 0.0 => step(i.raw.y), _ if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO => None, _ => Some(Wheel::Pan(i.scroll)), @@ -149,15 +171,45 @@ fn alt_id() -> Id { Id::new("pc-wheel-alt") } -/// Read this frame's wheel input. Call it every frame (hovered or not) so the Alt state of the -/// gesture follows the latest wheel event. -pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool) -> Option { +fn shift_id() -> Id { + Id::new("pc-wheel-shift") +} + +fn snap_residual_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-residual").with(owner) +} + +fn snap_time_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-time").with(owner) +} + +fn snap_frame_result_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-frame-result").with(owner) +} + +fn active_snap_owner_id() -> Id { + Id::new("pc-wheel-active-snap-owner") +} + +const SNAP_IDLE_TIMEOUT: f64 = 0.5; + +/// Read this frame's wheel input. Call it every frame (hovered or not) so the Alt and Shift +/// states of the gesture follow the latest wheel event. `owner` scopes the snap-zoom +/// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving +/// navigation may add raw input to the snap residual. +pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); - let (scroll, zoom_delta, latest, wheel, page) = ctx.input(|i| { - let latest = i.events.iter().rev().find_map(|e| match e { + + // --- Modifier tracking (always runs for all callers) --- + let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| { + let latest_alt = i.events.iter().rev().find_map(|e| match e { Event::MouseWheel { modifiers, .. } => Some(modifiers.alt && !modifiers.command && !modifiers.ctrl), _ => None, }); + let latest_shift = i.events.iter().rev().find_map(|e| match e { + Event::MouseWheel { modifiers, .. } => Some(modifiers.shift && !modifiers.command && !modifiers.ctrl), + _ => None, + }); let wheel: Vec<(MouseWheelUnit, Vec2, Modifiers)> = i .events .iter() @@ -166,29 +218,106 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool) -> Option None, }) .collect(); - (i.smooth_scroll_delta, i.zoom_delta(), latest, wheel, i.content_rect().height()) + (i.smooth_scroll_delta, i.zoom_delta(), latest_alt, latest_shift, wheel, i.content_rect().height()) }); - let alt = match latest { + let alt = match latest_alt { Some(a) => { ctx.data_mut(|d| d.insert_temp(alt_id(), a)); a } None => ctx.data(|d| d.get_temp::(alt_id())).unwrap_or(false), }; - // The wheel events that belong to the gesture in progress: Alt ones, or plain ones (⇧ and - // ⌘/Ctrl wheel scroll sideways and never zoom). - let raw = wheel.iter().filter(|(_, _, m)| if alt { m.alt && !m.command && !m.ctrl } else { !m.alt && !m.shift && !m.command && !m.ctrl }).fold( - Vec2::ZERO, - |sum, (unit, delta, _)| { + let shift = match latest_shift { + Some(s) => { + ctx.data_mut(|d| d.insert_temp(shift_id(), s)); + s + } + None => ctx.data(|d| d.get_temp::(shift_id())).unwrap_or(false), + }; + + if !hovered { + return None; + } + + // --- Input classification (hovered viewport only) --- + // The wheel events that belong to the gesture in progress: Shift+Alt (snap zoom), Alt-only + // (continuous zoom), Shift-only (snap zoom with zoom_with_wheel), or plain (scroll/zoom). + let raw = wheel + .iter() + .filter(|(_, _, m)| { + if alt && shift { + m.alt && m.shift && !m.command && !m.ctrl + } else if alt { + m.alt && !m.shift && !m.command && !m.ctrl + } else if shift { + m.shift && !m.alt && !m.command && !m.ctrl + } else { + !m.alt && !m.shift && !m.command && !m.ctrl + } + }) + .fold(Vec2::ZERO, |sum, (unit, delta, _)| { sum + *delta * match unit { MouseWheelUnit::Point => 1.0, MouseWheelUnit::Line => notch, MouseWheelUnit::Page => page, } - }, - ); - classify(Input { scroll, raw, zoom_delta, alt, zoom_with_wheel, pinch_zooms, notch }) + }); + let snap_mode = shift && ((alt && !zoom_with_wheel) || (!alt && zoom_with_wheel)); + let has_pinch = pinch_zooms && zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0; + + if snap_mode && !has_pinch { + let dy = raw.y + raw.x; + if dy == 0.0 { + return None; + } + + // Per-frame idempotency: a second read in the same frame returns the cached result. + let frame_nr = ctx.cumulative_pass_nr(); + if let Some((cached_frame, cached_steps)) = ctx.data(|d| d.get_temp::<(u64, i32)>(snap_frame_result_id(owner))) + && cached_frame == frame_nr + { + return if cached_steps == 0 { None } else { Some(Wheel::SnapZoom(cached_steps)) }; + } + + // When the receiving viewport changes, clear the old viewport's residual so a + // partial gesture doesn't reappear when returning to it. + let prev_active = ctx.data(|d| d.get_temp::(active_snap_owner_id())); + if let Some(old) = prev_active.filter(|&p| p != owner) { + ctx.data_mut(|d| { + d.remove::(snap_residual_id(old)); + d.remove::(snap_time_id(old)); + }); + } + ctx.data_mut(|d| d.insert_temp(active_snap_owner_id(), owner)); + + let now = ctx.input(|i| i.time); + let last_time = ctx.data(|d| d.get_temp::(snap_time_id(owner))); + let prev = if last_time.is_some_and(|t| now - t < SNAP_IDLE_TIMEOUT && (now - t).is_finite()) { + ctx.data(|d| d.get_temp::(snap_residual_id(owner))).unwrap_or(0.0) + } else { + 0.0 + }; + let notch_val = if notch.is_finite() && notch > 0.0 { notch } else { 40.0 }; + let total = prev + dy; + let n = if total.is_finite() { (total / notch_val).trunc() } else { 0.0 }; + let remainder = total - n * notch_val; + let steps = n.clamp(-10.0, 10.0) as i32; + ctx.data_mut(|d| { + d.insert_temp(snap_residual_id(owner), if remainder.is_finite() { remainder } else { 0.0 }); + d.insert_temp(snap_time_id(owner), now); + d.insert_temp(snap_frame_result_id(owner), (frame_nr, steps)); + }); + if steps == 0 { None } else { Some(Wheel::SnapZoom(steps)) } + } else { + if !snap_mode { + ctx.data_mut(|d| { + d.remove::(snap_residual_id(owner)); + d.remove::(snap_time_id(owner)); + }); + } + classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch }) + } } #[cfg(test)] @@ -198,7 +327,7 @@ mod tests { use super::*; fn input(scroll: Vec2, alt: bool) -> Input { - Input { scroll, raw: scroll, zoom_delta: 1.0, alt, zoom_with_wheel: false, pinch_zooms: true, notch: 40.0 } + Input { scroll, raw: scroll, zoom_delta: 1.0, alt, shift: false, zoom_with_wheel: false, pinch_zooms: true, notch: 40.0 } } fn wheel(delta: Vec2, modifiers: Modifiers) -> Event { @@ -210,10 +339,11 @@ mod tests { /// Feed `events` through an egui context set up like the app's (`legacy`: the Windows pinch /// fold on) and add up what the wheel did to the view over the following second. - fn gesture(frames: Vec>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32) { + fn gesture(frames: Vec>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32, i32) { + let owner = Id::new("test-gesture"); let ctx = Context::default(); configure(&ctx); - let (mut pan, mut zoom) = (Vec2::ZERO, 1.0); + let (mut pan, mut zoom, mut snaps) = (Vec2::ZERO, 1.0, 0i32); let mut frames = frames.into_iter(); for n in 0..60 { let mut raw = @@ -221,14 +351,19 @@ mod tests { if legacy { fold_legacy_pinch(&ctx, &mut raw); } - let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true) { + let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true, owner, true) { Some(Wheel::Pan(d)) => pan += d, Some(Wheel::Zoom(f)) => zoom *= f, + Some(Wheel::SnapZoom(n)) => snaps += n, None => {} }); out.textures_delta.clear(); } - (pan, zoom) + (pan, zoom, snaps) + } + + fn wheel_point(delta: Vec2, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit: MouseWheelUnit::Point, delta, phase: TouchPhase::Move, modifiers } } fn near(a: Vec2, b: Vec2) -> bool { @@ -321,37 +456,94 @@ mod tests { assert_eq!(classify(pref(Vec2::new(40.0, 0.0), false)), Some(Wheel::Pan(Vec2::new(40.0, 0.0))), "sideways still scrolls"); } + #[test] + fn shift_scroll_snaps_to_zoom_ladder_with_preference() { + let snap = |dy: f32| Input { shift: true, zoom_with_wheel: true, ..input(Vec2::new(0.0, dy), false) }; + assert_eq!(classify(snap(40.0)), Some(Wheel::SnapZoom(1)), "one notch up → +1 step"); + assert_eq!(classify(snap(-40.0)), Some(Wheel::SnapZoom(-1)), "one notch down → −1 step"); + assert_eq!(classify(snap(80.0)), Some(Wheel::SnapZoom(2)), "two notches → +2 steps"); + assert_eq!(classify(snap(0.0)), None, "no scroll → nothing"); + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + let snap_h = |dx: f32| Input { shift: true, zoom_with_wheel: true, raw: Vec2::new(dx, 0.0), ..input(Vec2::new(dx, 0.0), false) }; + assert_eq!(classify(snap_h(40.0)), Some(Wheel::SnapZoom(1)), "horizontal notch → +1 step"); + assert_eq!(classify(snap_h(-40.0)), Some(Wheel::SnapZoom(-1)), "horizontal notch down → −1 step"); + } + + #[test] + fn shift_scroll_tail_does_not_snap_or_pan() { + let tail = Input { shift: true, zoom_with_wheel: true, raw: Vec2::ZERO, ..input(Vec2::new(12.0, 0.0), false) }; + assert_eq!(classify(tail), None, "smoothed tail of ⇧ + scroll produces nothing"); + } + + #[test] + fn shift_alt_scroll_snaps_without_preference() { + let snap = |dy: f32| Input { shift: true, ..input(Vec2::new(0.0, dy), true) }; + assert_eq!(classify(snap(40.0)), Some(Wheel::SnapZoom(1)), "⇧ + ⌥ + notch up → +1 step"); + assert_eq!(classify(snap(-40.0)), Some(Wheel::SnapZoom(-1)), "⇧ + ⌥ + notch down → −1 step"); + assert_eq!(classify(snap(0.0)), None, "no scroll → nothing"); + } + + #[test] + fn shift_only_without_zoom_with_wheel_does_not_snap() { + let i = Input { shift: true, zoom_with_wheel: false, ..input(Vec2::new(0.0, 40.0), false) }; + assert_eq!(classify(i), Some(Wheel::Pan(Vec2::new(0.0, 40.0))), "⇧ alone without the preference: no zoom"); + } + + #[test] + fn shift_scroll_snaps_through_egui_context() { + let notch = Vec2::new(0.0, 1.0); + // Shift + wheel with zoom_with_wheel on. + let (pan, zoom, snaps) = gesture(vec![vec![wheel(notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "⇧ + wheel with zoom_with_wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + let (_, _, snaps) = gesture(vec![vec![wheel(-notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, -1, "⇧ + wheel down → snap out"); + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + let h_notch = Vec2::new(1.0, 0.0); + let (pan, zoom, snaps) = gesture(vec![vec![wheel(h_notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "⇧ + horizontal wheel with zoom_with_wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + // Shift + Alt + wheel without the preference. + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let (pan, zoom, snaps) = gesture(vec![vec![wheel(notch, shift_alt)]], false, false); + assert_eq!(snaps, 1, "⇧ + ⌥ + wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + } + #[test] fn each_modifier_maps_like_photoshop() { let notch = Vec2::new(0.0, 1.0); let one = |m: Modifiers| gesture(vec![vec![wheel(notch, m)]], false, false); - let (pan, zoom) = one(Modifiers::NONE); + let (pan, zoom, _) = one(Modifiers::NONE); assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "wheel scrolls vertically: {pan:?} {zoom}"); for m in [Modifiers::SHIFT, CTRL, CMD, Modifiers::CTRL] { - let (pan, zoom) = one(m); + let (pan, zoom, _) = one(m); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "{m:?} + wheel scrolls sideways: {pan:?} {zoom}"); } - let (pan, zoom) = one(Modifiers::ALT); + let (pan, zoom, _) = one(Modifiers::ALT); assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "⌥ + wheel zooms 10%, at once: {pan:?} {zoom}"); // Zoom with Scroll Wheel on: swapped. - let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false); assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "{pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false); assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "{pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}"); } #[test] fn pinch_zooms_and_ctrl_wheel_pans() { // macOS trackpad and browsers: the pinch is its own event. - let (pan, zoom) = gesture(vec![vec![Event::Zoom(1.1)], vec![Event::Zoom(1.1)]], false, false); + let (pan, zoom, _) = gesture(vec![vec![Event::Zoom(1.1)], vec![Event::Zoom(1.1)]], false, false); assert!(pan == Vec2::ZERO && (zoom - 1.21).abs() < 1e-4, "{pan:?} {zoom}"); // Windows: a touchpad pinch is Ctrl + wheel in fractions of a notch, a mouse wheel whole notches. - let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), CTRL)], vec![wheel(Vec2::new(0.0, 0.5), CTRL)]], false, true); + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), CTRL)], vec![wheel(Vec2::new(0.0, 0.5), CTRL)]], false, true); let speed = egui::InputOptions::default().scroll_zoom_speed; assert!(pan == Vec2::ZERO && (zoom - (speed * 40.0 * 0.75).exp()).abs() < 1e-4, "the pinch zooms: {pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], false, true); + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], false, true); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "Ctrl + mouse wheel scrolls sideways: {pan:?} {zoom}"); } @@ -369,7 +561,7 @@ mod tests { #[test] fn native_pinch_is_not_zoomed_twice_when_ctrl_wheel_arrives_in_the_same_frame() { - let (_, zoom) = gesture(vec![vec![Event::Zoom(1.2), wheel(Vec2::new(0.0, 0.25), CTRL)]], false, true); + let (_, zoom, _) = gesture(vec![vec![Event::Zoom(1.2), wheel(Vec2::new(0.0, 0.25), CTRL)]], false, true); assert!((zoom - 1.2).abs() < 1e-4, "native zoom must win over fallback: {zoom}"); } @@ -418,11 +610,36 @@ mod tests { for alt in [false, true] { for notch in [0.0, -1.0, f32::NAN, 40.0] { for zd in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0] { - let i = - Input { scroll: Vec2::new(0.0, s), raw: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: !alt, pinch_zooms: true, notch }; + let i = Input { + scroll: Vec2::new(0.0, s), + raw: Vec2::new(0.0, s), + zoom_delta: zd, + alt, + shift: false, + zoom_with_wheel: !alt, + pinch_zooms: true, + notch, + }; if let Some(Wheel::Zoom(f)) = classify(i) { assert!(f.is_finite() && f > 0.0, "{f} from {i:?}"); } + let si = Input { + shift: true, + zoom_with_wheel: true, + ..Input { + scroll: Vec2::new(0.0, s), + raw: Vec2::new(0.0, s), + zoom_delta: zd, + alt: false, + shift: false, + zoom_with_wheel: true, + pinch_zooms: true, + notch, + } + }; + if let Some(Wheel::SnapZoom(n)) = classify(si) { + assert!(n.abs() <= 10, "snap steps clamped: {n} from {si:?}"); + } } let mut ev = [wheel(Vec2::new(s, 0.5), CTRL)]; let _ = legacy_pinch_to_zoom(&mut ev, f64::NAN, Some(f64::NAN), notch, 0.005); @@ -434,4 +651,230 @@ mod tests { } } } + + /// A gesture helper that runs each frame at 1/60 s spacing with Point-unit events, + /// collecting snaps, and using a specific owner ID. + fn snap_gesture(frames: Vec>, zoom_with_wheel: bool, owner: Id) -> i32 { + let ctx = Context::default(); + configure(&ctx); + let mut snaps = 0i32; + let mut frames = frames.into_iter(); + for n in 0..60 { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events: frames.next().unwrap_or_default(), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), zoom_with_wheel, true, owner, true) { + snaps += s; + } + }); + out.textures_delta.clear(); + } + snaps + } + + #[test] + fn fractional_point_events_accumulate_across_frames() { + let quarter = Vec2::new(0.0, 10.0); + let batched = snap_gesture(vec![vec![wheel_point(Vec2::new(0.0, 40.0), Modifiers::SHIFT)]], true, Id::new("accum-batched")); + let split = snap_gesture( + vec![ + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-split"), + ); + assert_eq!(batched, 1, "40 points batched → 1 step"); + assert_eq!(split, 1, "4 × 10 points split → 1 step"); + } + + #[test] + fn fractional_accumulation_negative_and_horizontal() { + let neg_quarter = Vec2::new(0.0, -10.0); + let neg_split = snap_gesture( + vec![ + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-neg"), + ); + assert_eq!(neg_split, -1, "4 × −10 points → −1 step"); + let h_quarter = Vec2::new(10.0, 0.0); + let h_split = snap_gesture( + vec![ + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-horiz"), + ); + assert_eq!(h_split, 1, "horizontal 4 × 10 → 1 step"); + } + + #[test] + fn accumulation_handles_empty_frames_and_direction_reversal() { + let snaps = snap_gesture( + vec![ + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![], // empty frame + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ], + true, + Id::new("accum-empty"), + ); + assert_eq!(snaps, 1, "empty frame preserves residual"); + let reversal = snap_gesture( + vec![vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], vec![wheel_point(Vec2::new(0.0, -15.0), Modifiers::SHIFT)]], + true, + Id::new("accum-reversal"), + ); + assert_eq!(reversal, 0, "30 − 15 = 15 points, below 1 notch → 0 steps"); + } + + #[test] + fn accumulation_shift_alt_without_preference() { + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let split = snap_gesture( + vec![ + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + ], + false, + Id::new("accum-shift-alt"), + ); + assert_eq!(split, 1, "Shift+Alt 4 × 10 → 1 step without preference"); + } + + #[test] + fn cross_viewport_does_not_leak_residual() { + let owner_a = Id::new("owner-a"); + let owner_b = Id::new("owner-b"); + let ctx = Context::default(); + configure(&ctx); + let mut snaps_a = 0i32; + let mut snaps_b = 0i32; + // Frame 0: 30 points over A (hovered), B not hovered. + let raw = + RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) { + snaps_a += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, false) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + assert_eq!(snaps_a, 0, "A: 30 points, below 1 notch"); + assert_eq!(snaps_b, 0, "B: not hovered, nothing"); + // Frame 1: 10 points, pointer moved to B (B hovered, A not). + let raw = RawInput { + time: Some(1.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, false) { + snaps_a += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + assert_eq!(snaps_b, 0, "B: only 10 points of its own, no snap"); + // Frame 2–4: 30 more points over B. + for n in 2..5 { + let raw = RawInput { + time: Some(f64::from(n) / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = read(ui.ctx(), true, true, owner_a, false); + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + } + assert_eq!(snaps_b, 1, "B: 10+10+10+10 = 40 → 1 step"); + // Frame 5: switch back to A — A's residual was cleared on the viewport switch. + let raw = RawInput { + time: Some(5.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) { + snaps_a += s; + } + let _ = read(ui.ctx(), true, true, owner_b, false); + }); + out.textures_delta.clear(); + assert_eq!(snaps_a, 0, "A: fresh start after switch, 30 points, no snap yet"); + } + + #[test] + fn two_reads_same_owner_same_frame_do_not_double_count() { + let owner = Id::new("same-owner"); + let ctx = Context::default(); + configure(&ctx); + let mut total_snaps = 0i32; + let raw = + RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 20.0), Modifiers::SHIFT)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + }); + out.textures_delta.clear(); + assert_eq!(total_snaps, 0, "20 points read twice must not become 40"); + // Now add another 20 in the next frame — should snap since accumulated 20+20=40. + let raw = RawInput { + time: Some(1.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 20.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + }); + out.textures_delta.clear(); + assert_eq!(total_snaps, 1, "20 + 20 across two frames → 1 step"); + } + + #[test] + fn accumulation_hostile_numbers() { + for dy in [1e30, -1e30, 1e-10, -1e-10] { + let snaps = snap_gesture(vec![vec![wheel_point(Vec2::new(0.0, dy), Modifiers::SHIFT)]], true, Id::new("accum-hostile")); + assert!(snaps.abs() <= 10, "hostile {dy} clamped: {snaps}"); + } + } + + #[test] + fn line_unit_snap_still_works_with_accumulation() { + let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "Line-unit notch produces 1 step"); + let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, -1.0), Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, -1, "Line-unit notch down produces −1 step"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..c6e62ecaa1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -246,8 +246,9 @@ pub fn panel_menu_button(ui: &mut Ui) -> Response { pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { let t = Tokens::get(ui.ctx()); + let translated = tl!(label); let font = theme::medium(12.5); - let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), font, t.text); + let galley = ui.painter().layout_no_wrap(translated.to_string(), font, t.text); let size = vec2(galley.size().x + 20.0, 24.0); let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); if selected { @@ -260,6 +261,7 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { } let color = if selected { t.text } else { t.text_dim }; ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, translated)); resp } @@ -382,6 +384,8 @@ struct LabelScrub { rect: Rect, /// How far right of the label the field may start (the whole row for a slider row). max_gap: f32, + /// Options captions defer interaction until the adjacent field bounds are known. + option_area: bool, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -400,9 +404,35 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } +/// A tool-options caption whose scrub target includes the adjacent field's outer box. +/// Keep the original label bounds for linking; register the hit area before the numeric editor. +pub fn scrub_option_label(ui: &Ui, label: &Response) { + let scrub = LabelScrub { + pass: ui.ctx().cumulative_pass_nr(), + label: label.id.with("pc-option-scrub"), + rect: label.rect, + max_gap: 24.0, + option_area: true, + steps: 0.0, + started: false, + dragging: false, + stopped: false, + }; + ui.data_mut(|d| d.insert_temp(label_scrub_id(), scrub)); +} + 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); + ui.data_mut(|d| { + d.insert_temp(label_scrub_id(), scrub); + d.insert_temp(resp.id.with("pc-scrub-linked"), false); + }); + resp +} + +fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: f32) -> LabelScrub { // Whether a field took this label's drags last frame: only then is it scrubby. let linked = ui.data(|d| d.get_temp::(resp.id.with("pc-scrub-linked"))).unwrap_or(false); if linked && resp.enabled() && (resp.hovered() || resp.dragged()) { @@ -432,21 +462,17 @@ fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { steps = acc.trunc(); ui.data_mut(|d| d.insert_temp(acc_id, acc - steps)); } - let scrub = LabelScrub { + LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: resp.id, - rect: resp.rect, + rect: original_rect, max_gap, + option_area: false, steps, started: resp.drag_started(), dragging: resp.dragged(), stopped: resp.drag_stopped(), - }; - ui.data_mut(|d| { - d.insert_temp(label_scrub_id(), scrub); - d.insert_temp(resp.id.with("pc-scrub-linked"), false); - }); - resp + } } /// The label drag left for the field at `rect` this frame, if its label is just left of it on @@ -503,7 +529,17 @@ fn value_field_box( let FieldOpts { decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); - let scrub = take_label_scrub(ui, rect); + let scrub = take_label_scrub(ui, rect).map(|s| { + if !s.option_area { + return s; + } + // A direct click+drag target avoids egui snapping the padding/suffix to the nearby + // numeric button. It is not focusable; the editor drawn next wins inside its bounds. + // Preserve a popup field's trailing arrow, and never extend outside the UI clip. + let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); + let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + label_scrub_response(ui, &resp, s.rect, s.max_gap) + }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); @@ -519,12 +555,13 @@ fn value_field_box( // 6 pt from the right edge), so a one-character unit like `%` leaves the number room (#3040). let suffix_font = theme::mono(11.0); let suffix_w = if suffix.is_empty() { 0.0 } else { ui.painter().layout_no_wrap(suffix.to_owned(), suffix_font.clone(), t.text_faint).size().x + 3.0 }; - // 1 pt higher than centred: digits have no descenders, so centred text looks low. The slot - // is at least one text row tall, so a slim box's number isn't pushed out of it (#2823). + // Centred: the digits' ink then sits within 0.2 pt of the box's centre, where a 1 pt lift + // (#1322) put it 0.9 pt high and the focus outline off-centre (#5150). The slot is at least + // one text row tall, so a slim box's number isn't pushed out of it (#2823). let font = theme::mono(12.0); let row_h = ui.painter().layout_no_wrap("0".into(), font.clone(), t.text).size().y; let slot_h = (rect.height() - 4.0).max(row_h).min(rect.height() - 2.0); - let cy = rect.center().y - 1.0; + let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; @@ -586,7 +623,11 @@ fn value_field_box( let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - ui.memory_mut(|m| m.request_focus(resp.id)); + // Only take focus back after a typed sum gave it up: re-requesting it while held resets + // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). + if !ui.memory(|m| m.has_focus(resp.id)) { + ui.memory_mut(|m| m.request_focus(resp.id)); + } // The text stays selected, as in Photoshop: typing after a step replaces the value. crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); @@ -1293,9 +1334,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, op dropdown_hovered(ui, id, current, options, width).0 } +/// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel +/// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). +pub fn dropdown_literal(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { + let (chosen, _) = dropdown_with(ui, id, current, options, width, false, false); + !chosen.is_empty() +} + /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false); + let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false, true); (!chosen.is_empty(), hovered) } @@ -1306,11 +1354,13 @@ pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &m /// in order (one per notch; the last is `current`) and the option under the pointer in the open /// list, none while the pointer rests where the wheel last stepped. pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (Vec, Option) { - dropdown_with(ui, id, current, options, width, true) + dropdown_with(ui, id, current, options, width, true, true) } -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool) -> (Vec, Option) { - let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—"); +#[allow(clippy::too_many_arguments)] +fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool, translate: bool) -> (Vec, Option) { + let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; + let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); let (mut chosen, mut hovered) = (Vec::new(), None); // The wheel's notches add up over the button and the open list alike. let wheel_id = ui.make_persistent_id(id).with("wheel"); @@ -1322,11 +1372,12 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); // The list opens on the press: press, drag onto an option and release to choose it (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let texts: Vec = options.iter().map(|(_, l)| shown(l)).collect(); let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + fit_widest_option(ui, texts.iter().map(|l| l.as_str())); // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). - let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); + let labels: Vec<&str> = texts.iter().map(|l| l.as_str()).collect(); let shown = options.iter().position(|(v, _)| v == current); if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { *current = v.clone(); @@ -1345,8 +1396,8 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); + for ((v, _), text) in options.iter().zip(texts.iter()) { + let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); } @@ -1842,9 +1893,9 @@ mod tests { h } - /// Digits have no descenders, so the number sits 1 pt above the box's centre to look centred. + /// The number's slot (and its focus outline) is centred in the box (#5150). #[test] - fn value_field_digits_sit_one_point_above_centre() { + fn value_field_number_slot_is_centred_in_its_box() { let mut h = Harness::new_ui_state( |ui, s: &mut (f32, egui::Rect)| { s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, super::FieldOpts::default()).1; @@ -1854,11 +1905,46 @@ mod tests { h.run(); let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); let field = h.state().1; - assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); + assert!((field.center().y - number.center().y).abs() < 0.01, "field {field:?}, number {number:?}"); + } + + /// The digits' ink, not just their slot, is centred in the box, and starts level with the + /// `%`'s, so the number lines up with the box and its unit (#5150). + #[test] + fn value_field_digits_ink_is_centred_and_level_with_the_unit() { + let scale = 4.0; + for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, s: &mut (f32, egui::Rect)| { + s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "%", 80.0, 0.0, super::FieldOpts::default()).1; + }, + (100.0, egui::Rect::NOTHING), + ); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let image = h.render().expect("render value field"); + let (field, suffix_left) = (h.state().1, h.state().1.right() - 14.0); + let px = |v: f32| (v * scale) as u32; + let bg = image.get_pixel(px(field.left() + 2.0), px(field.center().y)).0; + let lum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Top and bottom of the ink (pt) between x0 and x1. + let ink = |x0: f32, x1: f32| { + let rows: Vec = (px(field.top() + 2.0)..px(field.bottom() - 2.0)) + .filter(|&y| (px(x0)..px(x1)).any(|x| lum(image.get_pixel(x, y).0) > lum(bg) + 150)) + .collect(); + let (top, bottom) = (rows.first().copied().unwrap_or(0), rows.last().copied().unwrap_or(0) + 1); + (top as f32 / scale, bottom as f32 / scale) + }; + let digits = ink(field.left() + 4.0, suffix_left - 2.0); + let unit = ink(suffix_left, field.right() - 2.0); + let what = format!("{kind:?}: field {field:?}, digits {digits:?}, unit {unit:?}"); + assert!(((digits.0 + digits.1) / 2.0 - field.center().y).abs() <= 0.25, "{what}"); + assert!((digits.0 - unit.0).abs() <= 0.25, "{what}"); + } } /// With the app's theme (its 24 pt interact height and 4 pt button padding), in a full-size - /// box and a slim status-bar box alike, the number stays inside the box, 1 pt above its centre, + /// box and a slim status-bar box alike, the number stays inside the box, centred in it, /// and a long number doesn't spill out of the box's left edge (#2823). #[test] fn themed_value_field_number_sits_inside_its_box_in_every_size() { @@ -1878,7 +1964,7 @@ mod tests { let field = harness.state().1; let what = format!("{kind:?} {w}x{h}: field {field:?}, number {number:?}"); assert!(field.contains_rect(number), "{what}"); - assert!((field.center().y - number.center().y - 1.0).abs() < 0.51, "{what}"); + assert!((field.center().y - number.center().y).abs() < 0.51, "{what}"); } } } @@ -1958,6 +2044,30 @@ mod tests { assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowDown), 10.0, "the field keeps focus"); } + /// #5474: a held arrow repeats on consecutive frames; every repeat steps the focused field + /// and none of them moves focus to the field above. + #[test] + fn held_arrow_keeps_stepping_the_focused_field() { + let mut h = Harness::new_ui_state( + |ui, s: &mut (f32, f32)| { + super::value_field(ui, &mut s.0, 0.0..=1000.0, "", 80.0); + super::value_field(ui, &mut s.1, 0.0..=1000.0, "", 80.0); + }, + (10.0, 59.0), + ); + h.run(); + h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(1).expect("second field").click(); + h.run(); + // Held down: key-down repeats on consecutive frames, no key-up in between. + for repeat in [false, true, true, true, true, true] { + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }); + h.step(); + } + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(*h.state(), (10.0, 65.0)); + } + #[test] fn arrow_keys_leave_an_unfocused_field_alone() { let mut h = field(100.0, 0.0..=1000.0); diff --git a/crates/ui-egui/src/zoom_levels.rs b/crates/ui-egui/src/zoom_levels.rs index 8d87e92cea..a77f6cc35c 100644 --- a/crates/ui-egui/src/zoom_levels.rs +++ b/crates/ui-egui/src/zoom_levels.rs @@ -74,16 +74,20 @@ pub fn percent_range(view: &crate::state::View) -> std::ops::RangeInclusive (min(view.doc_size).min(z) * 100.0)..=(MAX.max(z) * 100.0) } -/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) from `zoom`, for a document of `size` px. -pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 { - let z = clamp(zoom, size); +/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) within an explicit range. +pub fn step_in_range(zoom: f32, dir: i32, lo: f32, hi: f32) -> f32 { + let z = if zoom.is_finite() { zoom.clamp(lo, hi) } else { 1.0 }; let next = if dir > 0 { - LADDER.iter().copied().find(|s| *s > z * 1.001).unwrap_or(MAX) + LADDER.iter().copied().find(|s| *s > z * 1.001).unwrap_or(hi) } else { - // Below the ladder's lowest step (reached by the wheel), Zoom Out stays put. LADDER.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(z) }; - clamp(next, size) + if next.is_finite() { next.clamp(lo, hi) } else { z } +} + +/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) from `zoom`, for a document of `size` px. +pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 { + step_in_range(zoom, dir, min(size), MAX) } #[cfg(test)] @@ -146,6 +150,18 @@ mod tests { assert_eq!(min([0, 0]), 0.001, "unknown size"); } + #[test] + fn step_in_range_respects_custom_bounds() { + assert_eq!(step_in_range(1.0, 1, 0.0001, 16.0), 2.0); + assert!((step_in_range(1.0, -1, 0.0001, 16.0) - 2.0 / 3.0).abs() < 1e-6); + assert_eq!(step_in_range(16.0, 1, 0.0001, 16.0), 16.0, "at max stays put"); + assert_eq!(step_in_range(0.0001, -1, 0.0001, 16.0), 0.0001, "at min stays put"); + assert_eq!(step_in_range(f32::NAN, 1, 0.0001, 16.0), 2.0, "NaN → 100% → step"); + assert_eq!(step_in_range(8.8, 1, 0.0001, 16.0), 12.0, "off-ladder steps up"); + assert_eq!(step_in_range(8.8, -1, 0.0001, 16.0), 8.0, "off-ladder steps down"); + assert_eq!(step(1.0, 1, PHOTO), step_in_range(1.0, 1, min(PHOTO), MAX), "step delegates"); + } + #[test] fn clamp_keeps_the_range_and_survives_bad_input() { assert_eq!(clamp(500.0, PHOTO), MAX); diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index ea99cff25c..7f1f4d5823 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -80,6 +80,12 @@ fn handle(k: &Knot, h: Handle) -> Point { } } +/// Whether the segment from knot `a` to knot `b` is straight: both of its handles retracted. +fn straight(a: &Knot, b: &Knot) -> bool { + let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; + retracted(a, Handle::Out) && retracted(b, Handle::In) +} + fn other(h: Handle) -> Handle { match h { Handle::In => Handle::Out, @@ -116,6 +122,39 @@ pub fn move_anchors(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Res Ok(()) } +/// The Curvature Pen's anchor drag: moves the anchors like [`move_anchors`], then recomputes the +/// handles of every smooth knot in the subpaths it touched with [`curvature_knots`], so the curve +/// keeps flowing smoothly through all its anchors, as Photoshop does. Corner knots keep their own +/// handles (moved with them). +pub fn move_anchors_curvature(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Result<(), String> { + move_anchors(path, anchors, d)?; + let mut subs: Vec = anchors.iter().map(|a| a[0]).collect(); + subs.sort_unstable(); + subs.dedup(); + for s in subs { + let sp = path.subpaths.get_mut(s).ok_or_else(|| format!("no subpath {s}"))?; + let pts: Vec<[f64; 2]> = sp.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + let corners: Vec = sp.knots.iter().map(|k| !k.smooth).collect(); + let fresh = curvature_knots(&pts, &corners, sp.closed); + for (k, f) in sp.knots.iter_mut().zip(fresh) { + if k.smooth { + k.in_ctrl = Point::new(f[1][0], f[1][1]); + k.out_ctrl = Point::new(f[2][0], f[2][1]); + } + } + } + Ok(()) +} + +/// The Curvature Pen's double-click on an anchor: a corner becomes smooth and a smooth knot a +/// corner (handles retracted), then the subpath reflows as [`move_anchors_curvature`] does, so the +/// segments around a new smooth point curve through it. +pub fn toggle_curvature_point(path: &mut Path, knot: [usize; 2]) -> Result<(), String> { + let k = knot_mut(path, knot)?; + *k = if k.smooth { Knot::corner(k.anchor.x, k.anchor.y) } else { Knot { smooth: true, ..*k } }; + move_anchors_curvature(path, &[knot], [0.0, 0.0]) +} + /// Puts handle `h` of a knot at `to`. A smooth knot turns its other handle to stay collinear, /// unless `independent` (Convert Point / ⌥), which makes it a corner whose handles move apart. pub fn move_handle(path: &mut Path, knot: [usize; 2], h: Handle, to: Point, independent: bool) -> Result<(), String> { @@ -145,10 +184,9 @@ pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option) -> R /// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth. pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> { let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + if straight(&a, &b) { return move_anchors(path, &[knot, end], d); } // B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is @@ -180,10 +218,9 @@ pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2 return Err("`t` must be strictly between 0 and 1".into()); } let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - let new = if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + let new = if straight(&a, &b) { // Hit testing and hover feedback use the cubic parameter, which is not linear even // when both handles are retracted. Keep the inserted corner at that same point. let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t); @@ -348,6 +385,60 @@ mod tests { path.subpaths[s].knots[k] } + /// A closed four-anchor curvature loop (all smooth), as the Curvature Pen draws it. + fn loop4() -> Path { + let pts = [[0.0, 50.0], [100.0, 0.0], [200.0, 50.0], [100.0, 100.0]]; + let knots = curvature_knots(&pts, &[false; 4], true) + .into_iter() + .map(|k| Knot::smooth(Point::new(k[0][0], k[0][1]), Point::new(k[1][0], k[1][1]), Point::new(k[2][0], k[2][1]))) + .collect(); + Path::new(vec![Subpath { closed: true, knots, op: Default::default() }]) + } + + #[test] + fn a_curvature_drag_reflows_every_smooth_knot_through_the_moved_anchor() { + let mut p = loop4(); + move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 60.0]).unwrap(); + let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]]; + let want = curvature_knots(&pts, &[false; 4], true); + for (k, w) in p.subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } + // The neighbours turned: not a plain move. + assert_ne!(at(&p, 0, 0).out_ctrl, at(&loop4(), 0, 0).out_ctrl); + } + + #[test] + fn a_curvature_drag_keeps_corner_handles_and_rejects_bad_indices() { + let mut p = curve(); + move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 10.0]).unwrap(); + assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&curve(), 0, 0), at(&curve(), 0, 2)), "the corners kept their handles"); + assert_eq!(at(&p, 0, 1).anchor, Point::new(50.0, 10.0)); + let before = curve(); + let mut q = curve(); + assert!(move_anchors_curvature(&mut q, &[[0, 9]], [1.0, 1.0]).is_err()); + assert!(move_anchors_curvature(&mut q, &[[4, 0]], [1.0, 1.0]).is_err()); + assert_eq!(q, before, "a refused edit changes nothing"); + } + + #[test] + fn a_curvature_double_click_turns_a_corner_smooth_and_back() { + // A closed square of corners, as the Pen draws it. + let square = || Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])]); + let mut p = square(); + toggle_curvature_point(&mut p, [0, 1]).unwrap(); + let k = at(&p, 0, 1); + assert!(k.smooth); + let want = curvature_knots(&[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], &[true, false, true, true], true)[1]; + assert_eq!([[k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], [want[1], want[2]], "its handles follow its neighbours"); + assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&square(), 0, 0), at(&square(), 0, 2)), "the corners around keep theirs"); + toggle_curvature_point(&mut p, [0, 1]).unwrap(); + assert_eq!(p, square(), "a second double-click makes it a corner again"); + assert!(toggle_curvature_point(&mut p, [0, 7]).is_err()); + assert!(toggle_curvature_point(&mut p, [3, 0]).is_err()); + assert_eq!(p, square()); + } + #[test] fn anchors_move_with_their_handles_once_each() { let mut p = curve(); diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index b3fd0a90f6..a586dfa1bf 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -109,8 +109,38 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } -/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. +/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks +/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile +/// curves cannot exhaust memory. +pub(crate) const MAX_PATH_POINTS: usize = 1 << 20; + +/// How much to coarsen the curves of `subpaths` at `tol` so the whole path stays within +/// [`MAX_PATH_POINTS`]: the share of each curve's pieces to keep (1 = all of them). +fn piece_scale<'a>(subpaths: impl IntoIterator, tol: f64) -> f64 { + // Subpath starts and straight segments take one point each and cannot be coarsened. + let (mut fixed, mut curves) = (0usize, 0usize); + for s in subpaths { + fixed = fixed.saturating_add(1); + for seg in s.segments() { + if is_line(&seg) { + fixed = fixed.saturating_add(1); + } else { + curves = curves.saturating_add(pieces(&seg, tol)); + } + } + } + let total = fixed.saturating_add(curves); + if total <= MAX_PATH_POINTS { 1.0 } else { MAX_PATH_POINTS as f64 / total as f64 } +} + +/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. A hostile +/// subpath is flattened more coarsely so it stays within a bounded number of points. pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { + flatten_scaled(s, tol, piece_scale([s], tol)) +} + +/// [`flatten_subpath`] keeping the share `scale` of each curve's pieces (at least one). +fn flatten_scaled(s: &Subpath, tol: f64, scale: f64) -> Polyline { let mut out = Polyline { closed: s.closed, ..Default::default() }; if let Some(k) = s.knots.first() { out.push((k.anchor.x, k.anchor.y), true); @@ -121,6 +151,7 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { continue; } let n = pieces(seg, tol); + let n = if scale < 1.0 { ((n as f64 * scale) as usize).max(1) } else { n }; for j in 1..=n { out.push(eval(seg, j as f64 / n as f64), j == n); } @@ -136,15 +167,18 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { out } -/// Flattens every subpath (see [`flatten_subpath`]). +/// Flattens every subpath (see [`flatten_subpath`]); a hostile path is flattened more coarsely +/// so the whole path stays within a bounded number of points. pub fn flatten_path(p: &Path, tol: f64) -> Vec { - p.subpaths.iter().map(|s| flatten_subpath(s, tol)).collect() + let scale = piece_scale(&p.subpaths, tol); + p.subpaths.iter().map(|s| flatten_scaled(s, tol, scale)).collect() } #[cfg(test)] mod tests { use super::*; use crate::shapes; + use photocraft_doc::Knot; #[test] fn lines_are_not_subdivided() { @@ -168,4 +202,26 @@ mod tests { assert!(p.pts.len() < 4 * (1 + (0.75 * 100.0 / tol.min(0.15)).sqrt() as usize + 1)); } } + + #[test] + fn hostile_curves_flatten_within_the_point_budget() { + // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far + // outside the canvas: each of these segments alone asks for the 65536-piece cap, so the + // path would flatten to ~1.3M points (#5195). + let segments = 20; + let knots = (0..=segments) + .map(|i| { + let a = Point::new(f64::from(i) * 50.0, f64::from(i % 2) * 1000.0); + Knot::smooth(a, Point::new(a.x - 3e7, a.y + 3e7), Point::new(a.x + 3e7, a.y - 3e7)) + }) + .collect(); + let path = Path::new(vec![Subpath { closed: false, knots, op: Default::default() }]); + let lines = flatten_path(&path, 0.01); + let points: usize = lines.iter().map(|l| l.pts.len()).sum(); + assert!(points > 1000 && points <= MAX_PATH_POINTS + segments as usize + 1, "{points} points"); + assert_eq!(flatten_subpath(&path.subpaths[0], 0.01).pts.len(), points); + // Ordinary paths are flattened exactly as before. + let circle = shapes::ellipse(0.0, 0.0, 200.0, 200.0); + assert_eq!(flatten_path(&circle, 0.01)[0], flatten_scaled(&circle.subpaths[0], 0.01, 1.0)); + } } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 44e70ce760..d336435492 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -43,10 +43,12 @@ pub const DEFAULT_TOLERANCE: f64 = 0.01; /// folded with the subpath operations; open subpaths are closed implicitly. pub fn fill_rasterizer(path: &Path, tol: f64) -> Rasterizer { let mut r = Rasterizer::new(path.inverted); + // One point budget for the whole path, then one edge sort for all components. + let polys: Vec> = flatten_path(path, tol).into_iter().map(|l| l.pts).collect(); for c in path.components() { - let polys: Vec> = path.subpaths[c.clone()].iter().map(|s| flatten_subpath(s, tol).pts).collect(); - r.add_component(&polys, path.effective_op(c.start), path.fill_rule); + r.push_component(polys.get(c.clone()).unwrap_or_default(), path.effective_op(c.start), path.fill_rule); } + r.sort_edges(); r } @@ -105,26 +107,26 @@ impl CompiledVectorMask { /// Effective mask values over `rect`, with invocation-local rasterization state. pub fn render(&self, rect: Rect) -> Vec { - let mut v = match &self.geometry { - MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => rasterizer.render(rect), - }; - self.apply_density(&mut v); - v + self.render_with(rect, Rasterizer::render) } /// Effective mask values without entering a thread pool. Use this while /// initializing a shared result that other Rayon workers may wait for. pub fn render_sequential(&self, rect: Rect) -> Vec { + self.render_with(rect, |rasterizer, rect| { + let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; + if !v.is_empty() { + rasterizer.render_into(rect, &mut v); + } + v + }) + } + + /// Mask values over `rect`, with a path's coverage from `coverage`. + fn render_with(&self, rect: Rect, coverage: impl FnOnce(&Rasterizer, Rect) -> Vec) -> Vec { let mut v = match &self.geometry { MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => { - let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; - if !v.is_empty() { - rasterizer.render_into(rect, &mut v); - } - v - } + MaskGeometry::Path(rasterizer) => coverage(rasterizer, rect), }; self.apply_density(&mut v); v diff --git a/crates/vector/src/map.rs b/crates/vector/src/map.rs index ab2e62e1e8..6078e604f8 100644 --- a/crates/vector/src/map.rs +++ b/crates/vector/src/map.rs @@ -15,13 +15,18 @@ use photocraft_geom::Point; pub fn map_points(path: &Path, f: impl Fn(Point) -> Point) -> Path { let mut out = path.clone(); for s in &mut out.subpaths { - for k in &mut s.knots { - *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; - } + map_knots(s, &f); } out } +/// Sends every anchor and control point of `s` through `f`, in place. +fn map_knots(s: &mut Subpath, f: &impl Fn(Point) -> Point) { + for k in &mut s.knots { + *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; + } +} + /// Pieces a path may be split into, in total: bounds the work and memory on hostile input. const MAX_PIECES: usize = 1 << 20; /// Deepest split of one segment (2^6 = 64 pieces). @@ -47,7 +52,9 @@ fn map_subpath(s: &Subpath, f: &impl Fn(Point) -> Point, tol: f64, depth: u32) - let n = s.knots.len(); let segs = s.segments(); if segs.is_empty() { - return map_points(&Path::new(vec![s.clone()]), f).subpaths.pop().unwrap_or_default(); + let mut out = s.clone(); + map_knots(&mut out, f); + return out; } // The mapped pieces of each segment (segment i runs from knot i to knot i + 1). let pieces: Vec> = segs.iter().map(|seg| map_segment(seg, f, tol, depth)).collect(); diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index ba2cdd21ff..eb6c4a943b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -37,6 +37,10 @@ impl Edge { } } +/// Polygon coordinates are clamped to `±COORD_LIMIT`: far outside any `i32` pixel rectangle, +/// and small enough that edge slopes and positions never overflow to infinity or NaN. +const COORD_LIMIT: f64 = 1e15; + /// A combination step: a component (set of polygons filled with `rule`) and how it folds into /// the result so far. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -66,6 +70,19 @@ impl Rasterizer { /// Adds a component made of closed polygons (open input is closed implicitly). pub fn add_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { + self.push_component(polys, op, rule); + self.sort_edges(); + } + + /// Restores the `y0` order of the edges after [`Self::push_component`]. The sort is stable, + /// so sorting once after many components orders the edges exactly like sorting after each. + pub(crate) fn sort_edges(&mut self) { + self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + } + + /// [`Self::add_component`] without restoring the edge order: callers adding many components + /// sort once at the end with [`Self::sort_edges`]. + pub(crate) fn push_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { let comp = self.comps.len() as u32; self.comps.push(Component { op, rule }); if op != PathOp::Combine && comp != 0 { @@ -77,11 +94,13 @@ impl Rasterizer { continue; } for i in 0..n { - let a = poly[i]; - let b = poly[(i + 1) % n]; + let (a, b) = (poly[i], poly[(i + 1) % n]); if !(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()) { continue; } + // Far beyond any pixel rectangle; keeps edge arithmetic from overflowing. + let clamp = |p: (f64, f64)| (p.0.clamp(-COORD_LIMIT, COORD_LIMIT), p.1.clamp(-COORD_LIMIT, COORD_LIMIT)); + let (a, b) = (clamp(a), clamp(b)); self.bounds = Some(match self.bounds { None => (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), Some(bb) => (bb.0.min(a.0.min(b.0)), bb.1.min(a.1.min(b.1)), bb.2.max(a.0.max(b.0)), bb.3.max(a.1.max(b.1))), @@ -90,10 +109,15 @@ impl Rasterizer { continue; } let (p, q, dir) = if a.1 < b.1 { (a, b, 1) } else { (b, a, -1) }; - self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy: (q.0 - p.0) / (q.1 - p.1) }); + let dxdy = (q.0 - p.0) / (q.1 - p.1); + // Only a vanishingly short edge has an infinite slope once clamped: like a + // horizontal edge it covers no area, and it would turn coverage into NaN. + if !dxdy.is_finite() { + continue; + } + self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy }); } } - self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); } /// Pixel bounds of everything that may be covered (`None` = nothing; the whole plane @@ -428,6 +452,43 @@ mod tests { assert!(r.render(Rect::new(0, 0, 4, 4)).iter().all(|v| *v == 1.0)); } + #[test] + fn huge_finite_coordinates_keep_edges_finite() { + // The diagonal runs from far left of row 3 to far right of row 4, crossing x = 0 at + // y = 4: row 3 is outside the triangle on the canvas, row 4 inside. Its slope used to + // overflow to infinity, and the NaN it gave at the edge's top dropped that boundary, + // filling row 3 (#5195). + let mut r = Rasterizer::new(false); + r.add_component(&[vec![(-1e308, 3.0), (1e308, 5.0), (-1e308, 5.0)]], PathOp::Combine, FillRule::NonZero); + let c = r.render(Rect::new(0, 0, 10, 8)); + assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + for (y, row) in c.chunks(10).enumerate() { + let expect = if y == 4 { 1.0 } else { 0.0 }; + assert!(row.iter().all(|v| (v - expect).abs() < 1e-3), "row {y}: {row:?}"); + } + } + + #[test] + fn sorting_edges_once_matches_sorting_per_component() { + // Components sharing top rows (tied `y0`s) and a subtraction: one stable sort at the end + // gives the exact edge order (and so the exact bits) of a sort after every component. + let comps = [ + (vec![square(1.0, 1.0, 6.0), vec![(2.5, 1.0), (9.0, 4.0), (3.0, 8.5)]], PathOp::Combine, FillRule::NonZero), + (vec![square(3.0, 1.0, 2.5)], PathOp::Subtract, FillRule::EvenOdd), + (vec![vec![(0.5, 1.0), (8.0, 1.0), (4.2, 7.7)]], PathOp::Exclude, FillRule::NonZero), + ]; + let (mut each, mut once) = (Rasterizer::new(false), Rasterizer::new(false)); + for (polys, op, rule) in &comps { + each.add_component(polys, *op, *rule); + once.push_component(polys, *op, *rule); + } + once.sort_edges(); + let rect = Rect::new(0, 0, 10, 10); + let (a, b) = (each.render(rect), once.render(rect)); + assert!(a.iter().zip(&b).all(|(p, q)| p.to_bits() == q.to_bits())); + assert!(sum(&a) > 1.0); + } + #[test] fn pixel_bounds_clip_upper_edge_at_i32_max() { let mut r = Rasterizer::new(false); diff --git a/crates/vector/src/stroke.rs b/crates/vector/src/stroke.rs index dba9473e8f..2e08ab68ba 100644 --- a/crates/vector/src/stroke.rs +++ b/crates/vector/src/stroke.rs @@ -3,6 +3,8 @@ //! together with the non-zero rule yields their exact union without computing offset curves. //! Dashing splits the flattened polylines by arc length first. +use std::borrow::Cow; + use photocraft_doc::{LineCap, LineJoin}; use crate::flatten::Polyline; @@ -86,8 +88,14 @@ fn area_radius(r: f64, step: f64) -> f64 { if step < 1e-9 { r } else { r * (step / step.sin()).sqrt() } } -fn circle(c: P, r: f64, tol: f64) -> Vec

{ - let n = ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8); +/// Points a full circle needs at `tol` (at least 8). +fn circle_points(r: f64, tol: f64) -> usize { + ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8) +} + +/// A circle of at most `arc_cap` points (`usize::MAX` = no limit, at least 4). +fn circle(c: P, r: f64, tol: f64, arc_cap: usize) -> Vec

{ + let n = circle_points(r, tol).min(arc_cap).max(4); let rr = area_radius(r, std::f64::consts::TAU / n as f64); (0..n) .map(|i| { @@ -97,28 +105,78 @@ fn circle(c: P, r: f64, tol: f64) -> Vec

{ .collect() } +/// Dash steps one stroke may spend, shared by all of its lines: it bounds the number of dashes +/// (and so of caps) for file-provided patterns. Past it, the rest is drawn solid. +const DASH_STEP_BUDGET: usize = 100_000; + +/// Outline points a stroke may spend before its round caps and joins get coarser (#5211). +const OUTLINE_POINT_BUDGET: usize = 1 << 20; + /// Stroke outline pieces for `lines`. Fill them with the non-zero rule. +/// +/// The outline is bounded: when full-resolution round caps and joins would exceed +/// [`OUTLINE_POINT_BUDGET`] points, every round cap and join gets the same, smaller number of +/// segments (down to 4 per circle and 1 per join; areas stay exact). Strokes under the budget +/// are unchanged and no piece is dropped, so the total is at most the larger of the budget and +/// that minimal tessellation, which is linear in the (budgeted) flattened points and dashes. pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec> { let mut out = Vec::new(); if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 { return out; } + let mut steps_left = DASH_STEP_BUDGET; + let mut pieces: Vec> = Vec::new(); for pl in lines { if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { continue; } if !style.dashes.is_empty() { - for dash in dash_polyline(pl, &style.dashes, style.dash_offset) { - stroke_one(&dash, style, tol, &mut out); - } + pieces.extend(dash_polyline_budgeted(pl, &style.dashes, style.dash_offset, &mut steps_left).into_iter().map(Cow::Owned)); } else { - stroke_one(pl, style, tol, &mut out); + pieces.push(Cow::Borrowed(pl)); } } + let arc_cap = round_arc_cap(&pieces, style, tol); + for pl in &pieces { + stroke_one(pl, style, tol, arc_cap, &mut out); + } out } -fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec>) { +/// Points per full circle allowed for round caps and joins so the outline of `pieces` stays +/// within [`OUTLINE_POINT_BUDGET`]; `usize::MAX` when full resolution fits. +fn round_arc_cap(pieces: &[Cow], style: &StrokeStyle, tol: f64) -> usize { + let (round_cap, round_join) = (style.cap == LineCap::Round, style.join == LineJoin::Round); + if !round_cap && !round_join { + return usize::MAX; + } + // Upper bounds: ribbons and miter/bevel pieces, full circles (caps and dots), round joins. + let (mut base, mut circles, mut joins) = (0usize, 0usize, 0usize); + for pl in pieces { + let m = pl.pts.len(); + if m == 0 { + continue; + } + base = base.saturating_add(m.saturating_mul(6)).saturating_add(8); + if round_cap && !(pl.closed && m > 2) { + circles = circles.saturating_add(if m == 1 { 1 } else { 2 }); + } + if round_join { + joins = joins.saturating_add(m); + } + } + // A join sweeps at most half a circle, plus its centre and two end points. + let cost = |n: usize| base.saturating_add(circles.saturating_mul(n)).saturating_add(joins.saturating_mul(n / 2 + 3)); + let full = circle_points(style.width * 0.5, tol); + if cost(full) <= OUTLINE_POINT_BUDGET { + return usize::MAX; + } + let left = OUTLINE_POINT_BUDGET.saturating_sub(base).saturating_sub(joins.saturating_mul(3)) as f64; + let per_circle = circles as f64 + joins as f64 * 0.5; + ((left / per_circle.max(1.0)) as usize).clamp(4, full) +} + +fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, arc_cap: usize, out: &mut Vec>) { let hw = style.width * 0.5; // Drop repeated points. let mut pts: Vec

= Vec::with_capacity(pl.pts.len()); @@ -145,7 +203,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec // A dot: only round and square caps draw something. let c = pts[0]; match style.cap { - LineCap::Round => emit(out, circle(c, hw, tol)), + LineCap::Round => emit(out, circle(c, hw, tol, arc_cap)), LineCap::Square => emit(out, vec![(c.0 - hw, c.1 - hw), (c.0 + hw, c.1 - hw), (c.0 + hw, c.1 + hw), (c.0 - hw, c.1 + hw)]), LineCap::Butt => {} } @@ -213,19 +271,19 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec (jn, true) => jn, (_, false) => LineJoin::Miter, }; - join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out); + join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, arc_cap, out); } } if !closed { let d0 = norm(sub(pts[1], pts[0])); let d1 = norm(sub(pts[n - 1], pts[n - 2])); - cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, out); - cap_piece(pts[n - 1], d1, hw, style.cap, tol, out); + cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, arc_cap, out); + cap_piece(pts[n - 1], d1, hw, style.cap, tol, arc_cap, out); } } #[allow(clippy::too_many_arguments)] -fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, out: &mut Vec>) { +fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, arc_cap: usize, out: &mut Vec>) { let c = cross(d1, d2); let d = dot(d1, d2); if c.abs() < 1e-12 && d > 0.0 { @@ -239,15 +297,14 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol match join { LineJoin::Bevel => emit(out, vec![v, a, b]), LineJoin::Miter => { - // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments. - let half = ((1.0 - d) * 0.5).max(0.0).sqrt(); // sin of half the turning angle + // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments, + // i.e. 1 / cos of half the turning angle. let cos_half = ((1.0 + d) * 0.5).max(0.0).sqrt(); if cos_half < 1e-9 || 1.0 / cos_half > miter_limit { emit(out, vec![v, a, b]); } else { let bis = norm(add(n1, n2)); let m = add(v, mul(bis, hw / cos_half)); - let _ = half; emit(out, vec![v, a, m, b]); } } @@ -267,7 +324,12 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol delta = std::f64::consts::PI * if cross(n1, d1) > 0.0 { 1.0 } else { -1.0 }; } a1 = a0 + delta; - let steps = ((delta.abs() / arc_step(hw, tol)).ceil() as usize).max(1); + let mut steps = (delta.abs() / arc_step(hw, tol)).ceil() as usize; + if arc_cap != usize::MAX { + // Over the outline budget: the join's share of an `arc_cap`-point circle. + steps = steps.min((arc_cap as f64 * delta.abs() / std::f64::consts::TAU).ceil() as usize); + } + let steps = steps.max(1); let rr = area_radius(hw, delta.abs() / steps as f64); let mut poly = vec![v, a]; for k in 1..steps { @@ -280,10 +342,10 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol } } -fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec>) { +fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, arc_cap: usize, out: &mut Vec>) { match cap { LineCap::Butt => {} - LineCap::Round => emit(out, circle(p, hw, tol)), + LineCap::Round => emit(out, circle(p, hw, tol, arc_cap)), LineCap::Square => { let n = mul(perp(dir), hw); let e = mul(dir, hw); @@ -294,6 +356,12 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec /// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px. pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec { + dash_polyline_budgeted(pl, pattern, offset, &mut DASH_STEP_BUDGET.clone()) +} + +/// [`dash_polyline`] spending at most `steps_left` dash steps (decremented); once they are +/// spent, the rest of the line is drawn solid. +fn dash_polyline_budgeted(pl: &Polyline, pattern: &[f64], offset: f64, steps_left: &mut usize) -> Vec { if pattern.is_empty() { return vec![pl.clone()]; } @@ -313,13 +381,16 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if pl.closed { + let mut pts = pl.pts.clone(); pts.push(pl.pts[0]); - knots.push(true); - } + pts.into() + } else { + pl.pts.as_slice().into() + }; + // Knot flags past the source's (and the repeated closing point) count as knots. + let knot_at = |i: usize| i >= pl.pts.len() || pl.knot.get(i).copied().unwrap_or(true); // Position in the pattern. let mut idx = 0usize; let mut left = pat[0]; @@ -336,23 +407,27 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if idx.is_multiple_of(2) { Some(Polyline { pts: vec![pts[0]], knot: vec![true], closed: false }) } else { None }; - let mut guard = 0usize; - for (wi, w) in pts.windows(2).enumerate() { + 'walk: for (wi, w) in pts.windows(2).enumerate() { let (a, b) = (w[0], w[1]); - let b_knot = knots[wi + 1]; + let b_knot = knot_at(wi + 1); let seg = len(sub(b, a)); let mut pos = 0.0; while seg - pos > 1e-12 { - guard += 1; - if guard > 100_000 { - // Bound allocations and time, including file-provided patterns. Commands - // reject patterns that exceed this budget before document mutation. - return Vec::new(); - } let step = left.min(seg - pos); - if step > 0.0 && pos + step == pos { - return Vec::new(); + if *steps_left == 0 || (step > 0.0 && pos + step == pos) { + // Past the step budget (which bounds allocations and time for file-provided + // patterns; commands reject such patterns before mutating the document) or + // dashes below the coordinates' precision: keep the dashes made so far and draw + // the rest of the line solid rather than dropping the whole stroke (#5195). + let here = if pos > 0.0 { add(a, mul(sub(b, a), pos / seg)) } else { a }; + let rest = cur.get_or_insert_with(|| Polyline { pts: vec![here], knot: vec![true], closed: false }); + for (j, p) in pts.iter().enumerate().skip(wi + 1) { + rest.pts.push(*p); + rest.knot.push(knot_at(j)); + } + break 'walk; } + *steps_left -= 1; pos += step; left -= step; let at_end = seg - pos <= 1e-12; @@ -451,11 +526,57 @@ mod tests { #[test] fn invalid_and_too_dense_dashes_finish_with_bounded_work() { let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false); - for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] { + for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33]] { assert!(dash_polyline(&pl, &pattern, 0.0).is_empty()); } assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty()); assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty()); + // Too dense for the step budget: bounded work, and the line is still drawn. + let d = dash_polyline(&pl, &[1e-8, 1e-8], 0.0); + assert!(!d.is_empty() && d.len() <= 50_001); + assert_eq!(d.last().and_then(|p| p.pts.last()), Some(&(100.0, 0.0))); + } + + #[test] + fn dashes_past_the_step_budget_keep_the_rest_of_the_line_solid() { + // An SVG `stroke-dasharray="0.5 0.5"` along 60000 px needs more steps than the budget: + // the stroke used to vanish entirely (#5195). The dashes made so far stay, the rest of + // the line (through its later vertices) is drawn solid. + let pl = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0)], false); + let d = dash_polyline(&pl, &[0.5, 0.5], 0.0); + assert!(d.len() > 40_000, "{} dashes", d.len()); + assert_eq!(d[0].pts, [(0.0, 0.0), (0.5, 0.0)]); + let tail = d.last().unwrap(); + assert_eq!(tail.pts.last(), Some(&(60_000.0, 1000.0))); + assert!(tail.pts.contains(&(60_000.0, 0.0)) && tail.pts.len() == tail.knot.len()); + // The solid tail starts where the dashes stopped (100000 steps of 0.5 px). + assert!((tail.pts[0].0 - 50_000.0).abs() < 1.0 && tail.pts[0].1 == 0.0, "{:?}", tail.pts[0]); + // Closed outlines finish the same way, back to their first point, where the solid tail + // joins the first dash (one piece with a join at the seam, no caps). + let outline = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0), (0.0, 1000.0)], true); + let d = dash_polyline(&outline, &[0.5, 0.5], 0.0); + let joined = &d[0]; + assert!(joined.pts.contains(&(60_000.0, 1000.0)) && joined.pts.contains(&(0.0, 1000.0)) && joined.pts.contains(&(0.0, 0.0))); + assert_eq!(joined.pts.last(), Some(&(0.5, 0.0))); + } + + #[test] + fn wide_round_capped_dashes_keep_the_outline_within_its_point_budget() { + let points = |polys: &[Vec

]| polys.iter().map(Vec::len).sum::(); + // 5000 dashes, each with two round caps of ~629 points at full resolution (#5211). + let st = StrokeStyle { width: 4000.0, cap: LineCap::Round, join: LineJoin::Round, dashes: vec![1.0, 9.0], ..Default::default() }; + let pl = line(&[(0.0, 0.0), (25_000.0, 0.0), (25_000.0, 25_000.0)], false); + let polys = stroke_polygons(std::slice::from_ref(&pl), &st, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET, "{} points", points(&polys)); + // No dash is dropped: a ribbon and two caps each. + assert!(polys.len() >= 3 * 5000, "{} pieces", polys.len()); + // Many tiny undashed subpaths: bounded too, every one still drawn. + let dots: Vec = (0..3000).map(|i| line(&[(f64::from(i), 0.0), (f64::from(i) + 0.5, 0.0)], false)).collect(); + let polys = stroke_polygons(&dots, &StrokeStyle { dashes: Vec::new(), ..st.clone() }, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET && polys.len() >= 3 * 3000, "{} points", points(&polys)); + // Under the budget, round caps keep their full resolution. + let one = stroke_polygons(&dots[..1], &StrokeStyle { dashes: Vec::new(), ..st }, 0.01); + assert!(one.iter().any(|p| p.len() == circle_points(2000.0, 0.01)) && circle_points(2000.0, 0.01) > 600); } #[test] diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 5aa5c03722..1a547bfb0b 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -25,10 +25,8 @@ type P = (f64, f64); pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> Path { let loops = contours(values, rect, outside); let mut subpaths = Vec::new(); + // `contours` only returns loops of three or more points. for (i, lp) in loops.iter().enumerate() { - if lp.len() < 3 { - continue; - } // Fit to half the tolerance: the worst point may deviate by the full amount only rarely. let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05), 1.5); s.op = if i == 0 { PathOp::Combine } else { PathOp::Exclude }; diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 16bb93772f..3fb88c8513 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-10-13 · **Change:** minor (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-10-14 · **Change:** minor (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 @@ -108,6 +108,7 @@ tablets against Photoshop side by side. |---|---|---| | .abr import (v1/v2/v6) and export, persistent presets, folders | done | `psd/src/abr.rs` | | Brushes panel: drag a folder into a folder | missing | #2014 | +| Dragged brush preset preview | done | Cached, translucent tip thumbnail plus name in panel list/grid and the preset picker; `ui-egui/src/brushes_tab.rs`, real pointer-drag test in `brushes_tab_tests.rs` (#4225, partial) | | Tool presets (.tpl) | partial | Tool Presets panel exists; .tpl import not found | | Import 500 brushes without blocking the UI | not run | P24 | @@ -123,3 +124,4 @@ 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 | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..3ad3892001 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,9 +53,11 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. +- `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`, `newCropPreset` / `deleteCropPreset`: the Crop tool's New / Delete Crop Preset… dialogs, fields `name` and `selected: [names]`; crop presets are `crop` tool presets, so `tool.presets.new/list/edit {"tool":"crop"}` drive them too) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once @@ -74,7 +76,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - 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. - 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. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing -- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` +- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) @@ -100,7 +102,7 @@ store expose and persist the same setting; scripts keep using canonical command |---|---| | `file.new` | `{"width":1920,"height":1080,"mode":"rgb","depth":8,"background":"white"}` | | `layer.new.layer` | `{"name":"Ink"}` | -| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | +| `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | @@ -111,6 +113,7 @@ store expose and persist the same setting; scripts keep using canonical command | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | +| `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | @@ -196,9 +199,10 @@ Each element describes one command, in registry order: | `enabled` | boolean | Whether the command can run in the current state | | `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | | `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | +| `keywords` | string, optional | Other names the command is found by (#5060), e.g. `select.rect`'s Elliptical Marquee; only present on commands that have them | Filtering narrows the same array without changing its elements: `serve`'s `engine.commands -{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +{filter}` and the MCP tool's `filter` keep commands whose id, label or keywords contain the text (case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). @@ -231,8 +235,11 @@ Values the app honours live: history states (every open document), effect-cache theme, canvas colour and border, checkerboard size and colours (CPU and GPU canvas), gamut warning colour and opacity, guide/grid/smart-guide colours and styles, grid spacing and subdivisions, ruler units (rulers, Info panel, Image Size default unit), image interpolation (Image Size default), -painting and other cursors, zoom with scroll wheel, Use Shift Key for Tool Switch, keyboard -shortcuts, hidden and coloured menu items, autosave interval and crash recovery, and the history +painting and other cursors, zoom with scroll wheel, Use Shift Key for Tool Switch, Bundle Arrow-Key +Nudges and Nudge Bundle Pause (arrow keys sent with `ui.key` that follow each other within the pause +nudge the layers, selection outline or Free Transform box as one history step; an agent calling +`layer.translate` or `select.transformSelection` itself gets the same with a shared `coalesce` key), +keyboard shortcuts, hidden and coloured menu items, autosave interval and crash recovery, and the history log text file. GPU on/off and the GPU tile size apply at the next launch. The desktop app stores them in `preferences.json` in the platform config directory (macOS @@ -272,8 +279,12 @@ presets and preferences still load. Automatic Recent configurations are separate With View › Snap on, tool gestures snap to the View › Snap To targets (guides and grid while they are shown, layer edges and centres, document bounds and centre, selection edges) within 8 screen pixels: Move tool drags (the moved layers' bounds), marquee, crop, shape, type-box and pen points, -Free Transform handles and drags inside the box, and guides. Holding Ctrl disables snapping for -the drag. With View › Show › Smart Guides on, the Move tool also snaps to other layers and draws +Free Transform handles and drags inside the box, and guides. Distort corners snap to enabled +guides and their intersections, using the actual corner even when its handle is grabbed off-centre. +Guide targets are excluded when Guides, Canvas Guides or Extras is hidden, for all snapping gestures. +Ctrl/Cmd corner distortion in Free Transform does the same with its enabled snap targets. Holding +Ctrl disables snapping for other gestures; Ctrl used for an allowed corner distortion keeps snapping. +With View › Show › Smart Guides on, the Move tool also snaps to other layers and draws magenta alignment lines. `ui.pointer` drives the same code, so agents get identical results. ## MCP bridge @@ -545,6 +556,17 @@ visibility reset when the dialog opens. HDR scopes are not implemented. See ### Editing and composing actions +Newly recorded Select Layer steps store the layer name instead of a document-specific ID, +and the Actions panel shows `Select Layer (Background)`. Duplicate names include a zero-based +`occurrence` in document walk order (including layers inside groups); keep that order consistent +between source and target documents. A missing name or occurrence stops playback; a name without +an occurrence is rejected when ambiguous. Names are captured at selection time, so a later +rename in the action does not change the earlier target. Numeric `layer` IDs remain supported +for direct commands and older recordings, which the panel labels `Select Layer (ID 26)`. +String-valued `layer` targets from imported Photoshop actions keep main's topmost-name matching +and show the same named label; `remove` selection mode is supported for either target form. +Re-record old Select Layer steps: their original names cannot be recovered from an ID alone. + Creating an action scrolls its row into view; newly recorded steps are revealed immediately. Drag a step above or below another step in the same action to reorder it. Drag an action header to reorder the action list. The insertion line marks the drop position; dragging near the rows' diff --git a/docs/development.md b/docs/development.md index 3c539f70bc..8bfc53df81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -111,7 +111,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | -| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639) | +| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | @@ -243,7 +243,9 @@ claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \ `doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds, bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), smart object sources (`smartSource`: `embedded` with its file name, or `linked` with a file path or, for a -PSD placed layer, the `Idnt` uuid its duplicates share), type +PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smartContents`: an `id` +every copy of the same contents has, and the number of `instances`; Replace Contents changes them +all and lists them in its result), type text, adjustment settings, channels and history, so agents can verify what they did without a screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks (title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 590b9febc5..2651f4c4d1 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-10-13 · **Change:** minor (PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (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; earlier: CMYK Invert, Posterize and Threshold on the inks; 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) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,21 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | +| 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | +| 2026-10-13 | minor | CMYK documents whose layers are plain raster layers plus Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on their own inks for display, export and flattening: the cmyk8/16 oracles for those seven adjustments pass; Photoshop-oracle floor 151 → 165 of 258 | +| 2026-10-13 | minor | CMYK Threshold compares the Rec. 601 luma of the inks' plain complement, as Photoshop does (every pixel of cmyk8/16 threshold-128.psd); oracle bad pixels 1.6% → 0.2% | +| 2026-10-13 | minor | CMYK Brightness/Contrast (modern and legacy) curves each ink's stored value (1 − ink), exact on destructive edits; cmyk8/16 oracle bad pixels 71% → 48–57% (the RGB composite's separation still differs) | +| 2026-10-13 | minor | Modern Brightness/Contrast: the brightness curve refit to Photoshop, so the rgb and gray 8/16/32 brightness-contrast oracles pass; Photoshop-oracle floor 145 → 151 of 258 | +| 2026-10-13 | minor | Lab documents of plain layers with Invert, Posterize, Threshold, Brightness/Contrast or Lab Levels/Curves adjustment layers composite on the stored L, a, b instead of clipped display RGB, and flattening keeps them: lab16 invert, posterize-4, levels, curves and legacy Brightness/Contrast and lab8 invert and posterize-4 pass; Photoshop-oracle floor 145 → 152 of 258 | +| 2026-10-13 | minor | Levels round the gamma and output steps to whole levels too (8-bit), and the gamma above 1 starts along a toe of slope 2^gamma that joins the power curve: psd-tools levels_rgb and rgb8 stack-levels-curves-hue-posterize pass, rgb8/gray8 levels.psd exact; Photoshop-oracle floor 145 → 146 of 258, psd-tools 236 → 238 of 309 | +| 2026-10-13 | minor | 32-bit documents: legacy Brightness/Contrast, Channel Mixer, Photo Filter and the Black & White tint keep values above 1 and below 0 as Photoshop does (rgb32/gray32 brightness-contrast-legacy, rgb32 channel-mixer and black-white-tint pass); Photoshop-oracle floor 145 → 149 of 258 | +| 2026-10-13 | minor | Mask feathers blur with exactly the feather's variance (three extended boxes; whole-pixel boxes rounded 6 px to 6.48): psd-tools mask-parameters-no-real-channel passes, layer_mask_data 27 % → 4 % off; psd-tools floor → 240 of 309 (main 239) | +| 2026-10-13 | minor | Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B of whole samples, as Photoshop does (8-bit: rounded to a level, halves down; 16-bit: against the level in 16 bits), not the Rec. 601 grey: psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 (main 239) | +| 2026-10-13 | minor | A pass-through group that is the base of a clipping group and holds an adjustment layer renders isolated, as Photoshop does (its blend modes still reach the backdrop): psd-tools passthrough_clipping_mask_adjustment passes (was 50 % of pixels off); psd-tools floor → 240 of 309 (main 239) | +| 2026-10-13 | minor | 16-bit Posterize bins the 16-bit sample and lands on 8-bit levels through a 16-bit step, as Photoshop does (RGB, Grayscale and the CMYK/Lab channel path): psd-tools posterize_16bits_rgb and posterize_16bits_grayscale pass; psd-tools floor → 241 of 309 (main 239) | +| 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | @@ -153,5 +168,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | +| 2026-10-11 | minor | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 0af8491d4f..bcb3ca81a9 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-10-13 · **Change:** minor (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; 2026-10-11: G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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; 2026-10-11: G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **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 @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 145/258 (56%; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + oracles 185/258 (72%; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -69,8 +69,9 @@ what an agent can't finish alone. (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, TOOL-214-7, are done). - Blur Gallery: Field Blur has its on-canvas pins (2026-10-11, #3373); Iris, Tilt-Shift, Spin and - Path Blur still edit their pins as parameter dialogs. + Blur Gallery: Field, Iris, Tilt-Shift and Spin Blur are edited on the image with Photoshop's pins + and handles (2026-10-11, #3373); Tilt-Shift has one band (the engine takes one), and Path Blur + (#1759) is still a parameter dialog. - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -263,6 +264,21 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | +| 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | +| 2026-10-13 | minor | G1: CMYK stacks with Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on the document's own inks (14 CMYK oracles pass); Photoshop-oracle floor 151 → 165 of 258 | +| 2026-10-13 | minor | CMYK Threshold compares the Rec. 601 luma of the inks' plain complement, as Photoshop does (every pixel of cmyk8/16 threshold-128.psd); oracle bad pixels 1.6% → 0.2% | +| 2026-10-13 | minor | G1: CMYK Brightness/Contrast (modern and legacy) curves each ink's stored value as Photoshop does; oracle bad pixels 71% → 48–57%, floor unchanged | +| 2026-10-13 | minor | G1: modern Brightness/Contrast's brightness curve refit to Photoshop (six oracles pass); Photoshop-oracle floor 145 → 151 of 258 | +| 2026-10-13 | minor | G1: Lab documents composite Invert, Posterize, Threshold, Brightness/Contrast and Lab Levels/Curves on their stored L, a, b (no clipped RGB round trip); seven Lab oracles pass; Photoshop-oracle floor 145 → 152 of 258 | +| 2026-10-13 | minor | G1: Levels round every step to whole levels and use Photoshop's shadow toe (slope 2^gamma); levels_rgb and stack-levels-curves-hue-posterize pass; Photoshop-oracle floor 145 → 146 of 258, psd-tools 236 → 238 of 309 | +| 2026-10-13 | minor | G1: 32-bit legacy Brightness/Contrast (pivot 127), Channel Mixer (1/1024 steps, constant ×255/512), Photo Filter and Black & White tint unclipped as Photoshop computes them, CPU and GPU; Photoshop-oracle floor 145 → 149 of 258 | +| 2026-10-13 | minor | G1: mask feathers blur with exactly the feather's variance; psd-tools mask-parameters-no-real-channel passes, layer_mask_data 27 % → 4 % off; psd-tools floor → 240 of 309 | +| 2026-10-13 | minor | G1: Threshold splits on Photoshop's luminosity of whole samples (8- and 16-bit rules exact on four oracles); psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 | +| 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 | +| 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); 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 | @@ -276,6 +292,8 @@ what an agent can't finish alone. | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | +| 2026-10-11 | minor | G7: Tilt-Shift's band (lines, turn dots) and Spin Blur's pins (outline handles, blur-angle ring) on the image; Path Blur remains a dialog | +| 2026-10-11 | minor | G7: Iris Blur's pins, outline, roundness and feather handles on the image too; Tilt-Shift, Spin and Path Blur remain dialogs | | 2026-10-11 | minor | G7: Field Blur pins are placed, dragged and tuned on the image (#3373); the other Blur Gallery filters remain dialogs | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | diff --git a/docs/localization-es.md b/docs/localization-es.md index cce7de461d..de47b78716 100644 --- a/docs/localization-es.md +++ b/docs/localization-es.md @@ -1,12 +1,12 @@ -# Spanish terminology +# Spanish terminology and dynamic UI labels -This note goes with the review of the Spanish catalog -(`crates/ui-egui/src/i18n/es.tsv`). The catalog already covered every measured key; the review -aligned its wording with the terminology a Spanish-speaking Photoshop user already knows, fixed -mistranslations, and made the register consistent. No proprietary translation resources, fonts, -screenshots or user documents are included: the terms are the ones a Spanish Photoshop user sees -in the product and its public help, and each was checked against the meaning of PhotoCraft's own -English label. +This note goes with the review and completion of the Spanish catalog +(`crates/ui-egui/src/i18n/es.tsv`). It already covered every key measured by +`cargo xtask i18n-coverage`; the terminology review aligned its wording with the vocabulary a +Spanish-speaking image editor already knows, fixed mistranslations, and made the register +consistent. A later pass added the 1,276 runtime-only labels it still lacked. No proprietary +translation resources, fonts, screenshots or user documents are included: every translation was +written from the meaning of PhotoCraft's own English label. ## Register and style @@ -17,6 +17,19 @@ English label. - Quotation marks are «angulares»: «{name}». - Neutral Spanish, matching Adobe's single Spanish catalog (for example «coma flotante»). +## Dynamic UI coverage + +After merging the newer upstream catalog rows, the catalog has 4,010 entries. It covers the +complete Italian runtime key set and retains two Spanish profile-prompt labels that other +catalogs also use. The additional labels include filter and adjustment parameters and choices, +Filter Gallery names, Camera Raw, Lighting Effects, History and Layers controls, metadata, +dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to +English because many are assembled dynamically and cannot be found by the static coverage scan. + +`spanish_covers_generated_filter_options_and_gallery_names` in `filter_dialog.rs` enumerates the +generated filter and adjustment surface and fails if a future label has no Spanish row. A few +unused legacy labels are retained so the complete catalogs stay aligned. + ## Terminology | English source | Spanish UI | Was | @@ -61,7 +74,9 @@ Raw, droplet, OpenType, sRGB, Wayland) are not translated. ## Out of scope -Engine error, status and parameter-help messages are still English in every language (see -[`localization-parity.md`](localization-parity.md)); this review does not change that. The review -was AI-assisted, row by row against Photoshop's Spanish terminology; a review by a native -speaker is still welcome. +Engine-generated error details, status text and parameter help that do not pass through the UI +catalog remain English in every language (see +[`localization-parity.md`](localization-parity.md)). User document, layer and preset names and +automation command IDs also remain unchanged. The completion was contributed by +@gorevyoneticisi with OpenAI Codex as an AI contributor under the repository's MIT OR +Apache-2.0 terms; a review by a native speaker is still welcome. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c73eb094b..0b2be85595 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -32,7 +32,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde |---|---|---|---:|---|---|---|---|---:| | English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h | | Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h | -| Spanish | `es` | 1,608 / 1,608 (100%) | 2,413 | as above | Latin | no | partial | 6–10 h | +| Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h | | Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h | | Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h | | French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h | @@ -47,7 +47,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse @@ -83,5 +84,6 @@ engine localization or native review is complete. | 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed | | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | +| 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | diff --git a/docs/scorecard.md b/docs/scorecard.md index c8e943ed53..c568ba6a39 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,9 +7,9 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | -| [Tools](#tools) | 29 | 13 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [Tools](#tools) | 33 | 11 | 0 | | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -92,9 +92,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Corpus | Directory | Files | Oracle pass floor | Round-trip floor | |---|---|---:|---:|---:| -| io corpus | `corpus/psd` | 170 | 146 | 169 | -| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 145 | 258 | +| io corpus | `corpus/psd` | 170 | 148 | 169 | +| psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | +| photoshop oracles | `corpus/photoshop` | 256 | 185 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -140,8 +140,8 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | -| TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) | -| TOOL-213-12 | Import an image file as a brush tip | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only brush.defineFromSelection and brush.presets.importAbr | +| TOOL-213-11 | Editable pressure curve, global and per control | done | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Per control: every brush Dynamic has its own curve (paint ResponseCurve, the same monotone cubic; Dynamic.curve, set by tools.setBrush, e.g. shapeDynamics.size.curve) applied to its Fade/Pressure/Tilt/Wheel/Rotation input in dynamics.rs dynamic_value; Brush Settings shows it behind each Control row's curve button (#3294) | +| TOOL-213-12 | Import an image file as a brush tip | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush.presets.importImage (path or data); Brushes panel and preset picker Import Brushes… take .abr or an image (#3279) | | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | @@ -154,9 +154,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | done | [#214](https://github.com/storytold/photocraft/issues/214) | Edit > Content-Aware Scale opens a scale-only Free Transform box (cas_transform.rs) with Amount, Protect and Protect Skin Tones in the options bar; edit.contentAwareScale carves a 384 px copy in the background for the live preview, and Enter runs it with the box's size and x/y as one step (#3096) | | TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | done | [#214](https://github.com/storytold/photocraft/issues/214) | analysis_ui.rs: image.rotation.arbitrary opens seeded with the ruler angle and the direction that straightens it; arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and cw/ccw for three measured lines | | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | -| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | +| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | @@ -172,11 +172,12 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result | +| TOOL-2259-1 | Arrow-key nudges of one thing (layers, the selection outline, the Free Transform box) that follow each other are one History state, with a preference to turn it off | done | [#2259](https://github.com/storytold/photocraft/issues/2259) | nudge_bundle.rs: a run shares one `coalesce` key while each nudge comes within Tools > Nudge Bundle Pause (1 s) and nothing else touched the document (an edit, Undo/Redo, a layer selection, a save end it); move_mods.rs runs layer.translate and select.transformSelection through it, transform_tool.rs folds box nudges into one session step. Tools > Bundle Arrow-Key Nudges off gives every press its own state. Selected pixels float until dropped, which is one state already; ⌥-nudges (a copy each) never join a run | | TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | ## Workspace and UI -**Settings that do nothing: 26 of 157** (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: 26 of 164** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. @@ -191,7 +192,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | | UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | -| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | +| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | @@ -231,7 +232,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| 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.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file | +| 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.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 81e6483201..c4bfd998e7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; 2026-10-11: retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; 2026-10-11: retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -174,7 +174,7 @@ parity for the area and are already contained in the dimension rows above. | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | diff --git a/docs/ui-design.md b/docs/ui-design.md index 13fd8b99f1..c6135ff744 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -97,7 +97,7 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon - **Windows and Linux:** the window has no OS decorations, so there is one bar, not two stacked. The top bar is the title bar (`titlebar.rs`): Minimize, Maximize/Restore and Close (red on hover, `caption_close` tokens) sit flush in the window's top-right corner, the free gap between the menus and the controls drags the window and a double-click there maximizes it, and invisible 5 pt edges (12 pt corners) resize it. Close runs File › Exit, so unsaved documents are asked about first. - **macOS:** the traffic lights sit over the integrated title strip (`integrated_titlebar`). -- **Narrow windows** drop controls that are also in a menu before anything overlaps: Discord (Help › Discord), then the theme toggle, then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). +- **Narrow windows** drop controls that are also in a menu before anything overlaps: the theme toggle (Preferences), then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). ## Compact tool controls @@ -173,6 +173,15 @@ monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed 300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts continue to control the document independently of UI scaling. +On a Wayland session with a fractional display scale, the interface is sharp only in a native +Wayland window, where the window is drawn at the display's physical resolution +(`wp_fractional_scale_v1`). Through Xwayland the compositor enlarges a lower-resolution window, so +everything is blurred and looks larger than the chosen scale (#1072). The window opens through +Xwayland when a pen is attached (#639), with Preferences › Performance › Linux display server = +X11, or with `WAYLAND_DISPLAY=` cleared; those are also the ways to get file drag and drop, which +native Wayland lacks (#386). Sharp text or drag and drop: on such a desktop it is one or the other +until winit supports Wayland drops. + Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom. Tiny, Small (the default), Medium and Large use 10/12, 1, 14/12 and 16/12 of the theme's original font sizes, preserving the relative sizes of headings, captions and numeric fields. Apply or OK diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5abc8727a4..7fbe8932cc 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-10-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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) · **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, @@ -53,6 +53,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Group remembers the last tool used | partial | L always resets to Lasso (#2608) | | Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) | | Edit Toolbar customization | done | `edit.toolbar` | +| Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | ## Free Transform (⌘T) and transform handles @@ -66,6 +67,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌘-corner distort, ⌘-edge skew, ⌘⌥⇧ perspective, ⇧ rotate in 15° steps | done | | | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | | Options bar X/Y, W/H % with link, angle | done | | +| With a selection, the box is the selection's rectangle and the marching ants follow the box live | done | `transform_frame`, `transform_tool.rs` (`draw_selection`): move, scale, rotate, skew, distort, perspective and warp, before the transform is applied; also the Move tool's Show Transform Controls (#5297) | | Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` | | Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | | Commit/cancel buttons in the options bar | missing | #2442 | @@ -116,11 +118,12 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| -| Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | +| Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | done | TOOL-214-10; `vector_ui.rs` (#3751) | | Handle on the last point, close-drag handle | partial | #1482 | | Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | +| Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | -| On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -139,13 +142,14 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | | W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | | Saved custom crop presets | missing | TOOL-214-9 | -| Content-Aware crop fill, Delete Cropped Pixels off | partial | | +| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | +| Delete Cropped Pixels off | partial | | ## Snapping, guides, rulers, precision | Behaviour | Status | Evidence | |---|---|---| -| Snap to guides, grid, layers, document bounds and centre, selection, slices; ⌘ disables while dragging | done | `engine/src/snap.rs`, `snap_ui.rs` | +| Snap to guides, grid, layers, document bounds and centre, selection, slices; Ctrl disables other gestures, while Distort corners snap to guides and intersections | done | `engine/src/snap.rs`, `snap_ui.rs` (actual-corner grab-offset regressions, #3632) | | Snap vector tools and transforms to the pixel grid | done | `snap_ui.rs:196` | | Smart guides (magenta alignment lines) | done | | | Smart guide distance and spacing labels | missing | | @@ -188,6 +192,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | +| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | ## Panels and workspace @@ -204,25 +209,32 @@ Paragraph; the rest open as in-app floating windows. | Color panel wheel and RGB/HSB/CMYK/Lab sliders | missing | UI-217-17, #2569 | | Window size and dock width remembered | done | UI-217-2 | | Command palette with recents | done | UI-217-7 | +| Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar | | Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | | UI scale presets | missing | UI-217-9, #2492 | -| History snapshots: camera button, snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | +| History snapshots: camera button, snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | +| Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | +| Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | +| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | +| 2026-10-12 | minor | Closed #3632: Distort and Ctrl/Cmd corner drags snap actual corners to guides and intersections; shared guide snapping respects Extras and Canvas Guides visibility, with offset, visibility and constrained-alignment regressions | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | +| 2026-10-14 | minor | History: a clicked snapshot stays selected and the trash deletes it; snapshot and state rows dragged onto the trash are deleted (#5324) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | @@ -232,4 +244,12 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | +| 2026-10-12 | minor | Crop tool Fill › Content-Aware Fill done (#3823); Delete Cropped Pixels off split into its own row, still partial | +| 2026-10-12 | minor | Layers panel: Clip Thumbnails to Layer Bounds frames each layer's pixels instead of the canvas (#4281) | +| 2026-10-12 | minor | Guides: ⌥-click turns a guide through the click; double-click opens Preferences › Guides, Grid & Slices | | 2026-10-13 | minor | Smart filter rows can be dragged up or down their stack to reorder the filters | +| 2026-10-13 | minor | On-canvas corner radius widgets on live rectangles (TOOL-214-12) | +| 2026-10-13 | minor | Curvature Pen edits the active path: drag anchors, click a segment to add, double-click smooth/corner, cursor badges | +| 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | +| 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | +| 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index f04c5f4eab..43d0eeef7d 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -35,25 +35,24 @@ integrate() { src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 - # If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is - # already managing this AppImage, do not write a second launcher (#3623); remove our - # own if left over from an earlier run, and still install the hicolor icons below. + # If another integrator already registered a .desktop whose Exec points at this + # AppImage, do not write a second launcher (#3623); remove our own if one is left + # over, and still install the hicolor icons below. $DESKTOPINTEGRATION is not that + # signal: PhotoCraft sets X-AppImage-Integrate=false, so AppImageLauncher skips the + # AppImage and still exports the variable on its binfmt bypass. Treating it as + # "already integrated" left the menu with no entry (#5534). apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 external=0 - if [ -n "${DESKTOPINTEGRATION:-}" ]; then - external=1 - else - for f in "$apps"/*.desktop; do - if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then - continue - fi - if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then - external=1 - break - fi - done - fi + for f in "$apps"/*.desktop; do + if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then + continue + fi + if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then + external=1 + break + fi + done if [ "$external" = 1 ]; then if [ -f "$apps/$DESK_ID.desktop" ]; then rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 475fb5ee5e..8c9e33e6bd 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -83,11 +83,27 @@ printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon= [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" rm -f "$ext_desktop" -# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry. +# 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false +# made it skip the AppImage. That is not an existing launcher, so AppRun still +# writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" +grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +before="$(stat -c %i "$DESKTOP")" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +# A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). +ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" +printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" +[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 7bec4ab443..ed3839e73f 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -44,7 +44,7 @@ id = "AUTO-219-5" target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop" status = "partial" issue = 219 -note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file" +note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file" [[item]] id = "AUTO-219-6" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 2123b30635..69163191ee 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -78,16 +78,16 @@ note = "brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on [[item]] id = "TOOL-213-11" target = "Editable pressure curve, global and per control" -status = "partial" +status = "done" issue = 213 -note = "Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation)" +note = "Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Per control: every brush Dynamic has its own curve (paint ResponseCurve, the same monotone cubic; Dynamic.curve, set by tools.setBrush, e.g. shapeDynamics.size.curve) applied to its Fade/Pressure/Tilt/Wheel/Rotation input in dynamics.rs dynamic_value; Brush Settings shows it behind each Control row's curve button (#3294)" [[item]] id = "TOOL-213-12" target = "Import an image file as a brush tip" -status = "missing" +status = "done" issue = 213 -note = "Only brush.defineFromSelection and brush.presets.importAbr" +note = "brush.presets.importImage (path or data); Brushes panel and preset picker Import Brushes… take .abr or an image (#3279)" [[item]] id = "TOOL-213-13" @@ -176,9 +176,9 @@ note = "Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution [[item]] id = "TOOL-214-10" target = "Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle" -status = "partial" +status = "done" issue = 214 -note = "Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg" +note = "Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751)" [[item]] id = "TOOL-214-11" @@ -192,7 +192,7 @@ id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" status = "partial" issue = 214 -note = "select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only" +note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" [[item]] id = "TOOL-214-13" @@ -299,6 +299,13 @@ status = "partial" issue = 213 note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result" +[[item]] +id = "TOOL-2259-1" +target = "Arrow-key nudges of one thing (layers, the selection outline, the Free Transform box) that follow each other are one History state, with a preference to turn it off" +status = "done" +issue = 2259 +note = "nudge_bundle.rs: a run shares one `coalesce` key while each nudge comes within Tools > Nudge Bundle Pause (1 s) and nothing else touched the document (an edit, Undo/Redo, a layer selection, a save end it); move_mods.rs runs layer.translate and select.transformSelection through it, transform_tool.rs folds box nudges into one session step. Tools > Bundle Arrow-Key Nudges off gives every press its own state. Selected pixels float until dropped, which is one state already; ⌥-nudges (a copy each) never join a run" + [[item]] id = "TOOL-1045-1" target = "Color Replacement tool on the toolbar" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 84f071b4a2..4ac0ce6386 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -73,7 +73,7 @@ id = "UI-217-10" target = "Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents" status = "partial" issue = 217 -note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents" +note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382)" [[item]] id = "UI-217-11" diff --git a/xtask/pixls.sha256 b/xtask/pixls.sha256 index 3878b45356..f76f983ba4 100644 --- a/xtask/pixls.sha256 +++ b/xtask/pixls.sha256 @@ -3,5 +3,9 @@ 6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c IMG_4059.CR2 7c24fefb1f73ad45c42a0be0078e15b7e91deab1d623efceebfc3c4d0d06135c P1000475.RW2 880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8 JD1_8203.NEF +8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 DSC_0751.NEF +3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 DSC_0754.NEF +b4cdb8ecf8a971ec023dd242251c6cbfb19966914e37c2311dc9813a7ab5e15f DSC_0750.NEF +13c1ec5c2d020b3038d9cb660bc0e0b5a52cb9fe12561dec9ba0e9919e9cbee2 DSC_0753.NEF ab0d54e3819c9372df0fa98c8686dba0ae87c9d43526a5dd6fd26bd2a664483d _7061961_copy.ORF f602f8ddeeb9b062f60bfdd3ba0d560c8e54f1f64dbc20e18df5222e2f37a428 DSC00009.ARW diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 917412709a..908557feea 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -253,6 +253,12 @@ pub const PIXLS_FILES: &[(&str, &str)] = &[ ("Canon/PowerShot%20SX50%20HS/IMG_4059.CR2", "IMG_4059.CR2"), ("Canon/PowerShot%20SX50%20HS/CRW_4061.DNG", "CRW_4061.DNG"), ("Nikon/D3/JD1_8203.NEF", "JD1_8203.NEF"), + // Modern lossy NEF curve: the encoded range is two bits smaller than the sensor ADUs. + ("Nikon/Z%206/DSC_0751.NEF", "DSC_0751.NEF"), + ("Nikon/Z%206/DSC_0754.NEF", "DSC_0754.NEF"), + // Uncompressed packed NEF selected by maker-note mode, despite TIFF Compression 34713. + ("Nikon/Z%206/DSC_0750.NEF", "DSC_0750.NEF"), + ("Nikon/Z%206/DSC_0753.NEF", "DSC_0753.NEF"), ("Sony/DSC-RX0/DSC00009.ARW", "DSC00009.ARW"), ("Panasonic/DC-G9/P1000475.RW2", "P1000475.RW2"), ("Olympus/E-1/E_1__C106743_gredos.ORF", "E_1__C106743_gredos.ORF"),