From f48a72cbe3488b6baad53a9e43bec31ebd88e7a2 Mon Sep 17 00:00:00 2001 From: Przemyslaw Majdak <31745530+przemyslvw@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:44:04 +0200 Subject: [PATCH 001/223] Codecs: open and save DPX frames (#1049) (#4104) Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16 bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit packing 0 or 1. 10- and 12-bit code values open as U16 round(c * 65535 / (2^b - 1)) and come back exactly when written at their depth. No colour transform: the transfer characteristic and colorimetric specification are reported as import notes; extra image elements are reported; the orientation is applied on import. Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits (EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC; `--dpx-bits` in the CLI), following the document depth by default. Gray with alpha is written as luma. Creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate and time code travel as DPX:* text metadata and are written back. Unsupported descriptors, signed or run-length-encoded data and other bit depths return CodecError::Unsupported; malformed and truncated files return errors. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 17 +- apps/photocraft-cli/tests/cli.rs | 34 ++ apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 5 +- crates/automation/src/rpc.rs | 1 + crates/automation/src/server.rs | 10 + crates/codecs/README.md | 14 + crates/codecs/src/codecs/dpx.rs | 656 +++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/fidelity.rs | 12 +- crates/codecs/src/format.rs | 13 +- crates/codecs/src/image.rs | 33 ++ crates/codecs/src/lib.rs | 7 +- crates/codecs/src/options.rs | 35 ++ crates/codecs/tests/detect_caps.rs | 1 + crates/codecs/tests/dpx.rs | 386 ++++++++++++++++ crates/codecs/tests/malformed.rs | 2 +- crates/codecs/tests/roundtrip.rs | 3 + crates/engine/src/file_cmds.rs | 29 +- crates/engine/src/file_cmds/tests.rs | 26 ++ crates/io/src/lib.rs | 1 + crates/io/tests/flat.rs | 22 + docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 24 files changed, 1296 insertions(+), 26 deletions(-) create mode 100644 crates/codecs/src/codecs/dpx.rs create mode 100644 crates/codecs/tests/dpx.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 7bf7f8164d..ce1cbeaf9e 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,18 +15,19 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. + --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -68,17 +69,17 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -214,6 +215,10 @@ fn export_opts(a: &Args) -> Result { if let Some(b) = a.get("--tga-bits") { o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); } + if let Some(b) = a.get("--dpx-bits") { + o.encode.dpx_bits = + Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + } Ok(o) } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 162483bcf9..231eb2c321 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -455,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() { assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); } +/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). +#[test] +fn dpx_converts_both_ways_with_the_bits_asked_for() { + let d = tmp("dpx"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { + let out = d.join("a.dpx"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--dpx-bits", b]); + } + ok(&mut convert); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); + } + let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); + let v: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); + assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); + assert!(back.exists()); + for bad in ["24", "0", "abc"] { + let out = d.join("bad.dpx"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); + assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 16781e42bb..daf4ed9efb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", - "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", + "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index fea0d34541..faf0b6a11f 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", + "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -62,6 +62,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("Targa", &["tga"]), ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), + ("DPX", &["dpx"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 875850460c..1599eb6a53 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -116,6 +116,7 @@ impl Headless { let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; + opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; if let Some(q) = p.get("quality").and_then(Value::as_u64) { opts.encode.jpeg_quality = q.clamp(1, 100) as u8; opts.encode.webp_quality = q.clamp(1, 100) as u8; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c2f18b8bd5..71c290cae7 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -167,6 +167,9 @@ pub struct SaveParams { /// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24. #[serde(default, rename = "tgaBits")] pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16. + #[serde(default, rename = "dpxBits")] + pub dpx_bits: Option, #[serde(default)] pub index: Option, } @@ -987,6 +990,11 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))), }; + let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) { + None => None, + Some((_, Some(bits))) => Some(bits), + Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), + }; if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless @@ -996,6 +1004,7 @@ impl PhotocraftMcp { p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), + dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] .into_iter() @@ -1016,6 +1025,7 @@ impl PhotocraftMcp { let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; + opts.encode.dpx_bits = dpx_bits; if let Some(q) = p.quality { opts.encode.jpeg_quality = q.clamp(1, 100); opts.encode.webp_quality = q.clamp(1, 100); diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e44890a0d9..8cba2b1f37 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -74,6 +75,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with `CodecError::Unsupported`; Bayer mosaics open undebayered. +* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element, + descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits. + 10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data + packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16 + `round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No + colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and + colorimetric specification, so a log frame is reported as log. More elements than the first + give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is + big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for + U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. + Other descriptors, signed or run-length-encoded data and other bit depths return + `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs new file mode 100644 index 0000000000..8b772707cc --- /dev/null +++ b/crates/codecs/src/codecs/dpx.rs @@ -0,0 +1,656 @@ +//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the +//! 1664-byte generic header (file and image information, image source), the 384-byte industry +//! header (film and television), then the image data of each element. +//! +//! Reading: +//! +//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian). +//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without +//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words +//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the +//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits +//! (1, method A). Lines start on a 32-bit word boundary. +//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16 +//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth. +//! * No colour transform: the transfer characteristic and colorimetric specification are reported +//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred. +//! * The image orientation is applied on import (the caller turns the image upright). +//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a +//! DPX export writes them back. +//! +//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with +//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image +//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType}; +use crate::options::{DpxBits, EncodeOptions, Limits}; + +const F: Format = Format::Dpx; +/// Generic plus industry header: where our image data starts. +const HEADER_LEN: usize = 2048; +const GENERIC_LEN: u32 = 1664; +const INDUSTRY_LEN: u32 = 384; +/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float). +const UNDEF32: u32 = u32::MAX; +const UNDEF8: u8 = u8::MAX; +/// Offset of the first image element and the size of one. +const ELEMENT0: usize = 780; +const ELEMENT_LEN: usize = 72; +const MAX_ELEMENTS: u16 = 8; + +// Header field offsets (generic file header, image information, film and TV headers). +const CREATION_TIME: (usize, usize) = (136, 24); +const CREATOR: (usize, usize) = (160, 100); +const PROJECT: (usize, usize) = (260, 200); +const COPYRIGHT: (usize, usize) = (460, 200); +const ORIENTATION: usize = 768; +const FRAME_POSITION: usize = 1712; +const FRAME_RATE: usize = 1724; +const TIME_CODE: usize = 1920; + +/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip. +pub(crate) const KEY_CREATOR: &str = "DPX:Creator"; +pub(crate) const KEY_PROJECT: &str = "DPX:Project"; +pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright"; +pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime"; +pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer"; +pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric"; +pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition"; +pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate"; +pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode"; +pub(crate) const KEYS: [&str; 9] = + [KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE]; + +fn err(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +/// The file's byte order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Order { + Big, + Little, +} + +impl Order { + fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { + b.get(at..at.checked_add(N)?)?.try_into().ok() + } + + fn u16(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<2>(b, at)?; + Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + + fn u32(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<4>(b, at)?; + Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } + + /// A 32-bit field that may be undefined (all ones). + fn defined_u32(self, b: &[u8], at: usize) -> Option { + self.u32(b, at).filter(|&v| v != UNDEF32) + } + + fn f32(self, b: &[u8], at: usize) -> Option { + self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite()) + } +} + +/// The byte order from the magic number, `None` when it is not DPX. +fn order(b: &[u8]) -> Option { + match b.get(..4)? { + b"SDPX" => Some(Order::Big), + b"XPDS" => Some(Order::Little), + _ => None, + } +} + +/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when +/// empty or undefined (all ones). +fn string(b: &[u8], (at, len): (usize, usize)) -> Option { + let raw = b.get(at..at.checked_add(len)?)?; + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect(); + Some(s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// The descriptive fields of image element 0 that decoding needs. +#[derive(Debug, Clone, Copy)] +struct Element { + signed: bool, + descriptor: u8, + transfer: u8, + colorimetric: u8, + bits: u8, + packing: u16, + encoding: u16, + data_offset: usize, + eol_padding: usize, +} + +struct Header { + order: Order, + width: u32, + height: u32, + elements: u16, + element: Element, +} + +fn parse(b: &[u8]) -> Result { + let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?; + o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?; + let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?; + if !(1..=MAX_ELEMENTS).contains(&elements) { + return Err(err(format!("invalid number of image elements {elements}"))); + } + let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?; + let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?; + let e = ELEMENT0; + let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1")); + let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1")); + let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1")); + let data_offset = match field(28)? { + 0 | UNDEF32 => image_offset, + v => v, + }; + let eol_padding = match field(32)? { + UNDEF32 => 0, + v => v, + }; + let element = Element { + signed: field(0)? == 1, + descriptor: byte(20)?, + transfer: byte(21)?, + colorimetric: byte(22)?, + bits: byte(23)?, + packing: half(24)?, + encoding: half(26)?, + data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?, + eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?, + }; + Ok(Header { order: o, width, height, elements, element }) +} + +/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7 +/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…). +/// Anything else, undefined included, is taken as the default (1). +pub(crate) fn orientation(b: &[u8]) -> u16 { + let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 }; + match o { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Bytes in one stored line of `samples` samples, padded to a 32-bit word. +fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option { + let words = match (bits, packing) { + // Three samples to a word, or one to a 16-bit half word. + (10, 1 | 2) => samples.div_ceil(3), + (12, 1 | 2) => samples.div_ceil(2), + _ => samples.checked_mul(usize::from(bits))?.div_ceil(32), + }; + words.checked_mul(4) +} + +/// Code values of one stored line. +fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> { + let short = || err("truncated image data"); + match (bits, packing) { + (8, _) => { + let src = line.get(..out.len()).ok_or_else(short)?; + for (v, &c) in out.iter_mut().zip(src) { + *v = u16::from(c); + } + } + (16, _) => { + for (i, v) in out.iter_mut().enumerate() { + *v = o.u16(line, i * 2).ok_or_else(short)?; + } + } + (10, 1 | 2) => { + let base = if packing == 1 { 22 } else { 20 }; + for (i, v) in out.iter_mut().enumerate() { + let word = o.u32(line, i / 3 * 4).ok_or_else(short)?; + *v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16; + } + } + (12, 1 | 2) => { + let shift = if packing == 1 { 4 } else { 0 }; + for (i, v) in out.iter_mut().enumerate() { + *v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF; + } + } + // Packing 0: one bit stream through the line's 32-bit words, high bits first. + _ => { + let mask = (1u64 << bits) - 1; + let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize); + for v in out.iter_mut() { + while have < u32::from(bits) { + acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?); + word += 1; + have += 32; + } + have -= u32::from(bits); + *v = ((acc >> have) & mask) as u16; + } + } + } + Ok(()) +} + +/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`. +fn widen(c: u16, max: u64) -> u16 { + ((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16 +} + +/// The inverse of [`widen`]: `round(v × max / 65535)`. +fn narrow(v: u16, max: u64) -> u16 { + ((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16 +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let h = parse(bytes)?; + let e = h.element; + let layout = match e.descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + 51 => ChannelLayout::Rgba, + d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))), + }; + if e.signed { + return Err(unsupported("data sign 1 (signed samples) is not supported")); + } + if e.encoding != 0 { + return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding))); + } + let packing_ok = match e.bits { + 8 | 16 => e.packing == 0, + 10 => e.packing <= 2, + 12 => e.packing <= 1, + b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))), + }; + if !packing_ok { + return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits))); + } + let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(h.width, h.height, layout, sample)?; + + let nc = layout.channels(); + let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let height = h.height as usize; + let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let too_large = || CodecError::LimitExceeded("image too large".into()); + let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?; + let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?; + // Lines run `stride` apart; the last one needs no padding after it. + let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len); + // Some writers pack 8- and 16-bit lines without padding them to a word; take that layout + // when the file is too short for padded lines but holds the unpadded ones. + let tight = per_line * usize::from(e.bits / 8); + if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) { + (line_len, stride) = (tight, tight); + } + if span(stride, line_len).ok_or_else(too_large)? > data.len() { + return Err(err("truncated image data")); + } + + let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?]; + for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() { + let start = y * stride; + let line = data.get(start..).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, h.order, e.bits, e.packing, row)?; + } + + let mut img = match e.bits { + 8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?, + 16 => Image::from_u16(h.width, h.height, layout, &codes)?, + b => { + let max = (1u64 << b) - 1; + let wide: Vec = codes.iter().map(|&c| widen(c, max)).collect(); + Image::from_u16(h.width, h.height, layout, &wide)? + } + }; + if h.elements > 1 { + img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) }); + } + if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 { + img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric }); + } + img.meta.text = header_text(bytes, h.order, e); + Ok(img) +} + +/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. +fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { + let mut text = Vec::new(); + let mut put = |k: &str, v: Option| { + if let Some(v) = v { + text.push((k.to_string(), v)); + } + }; + put(KEY_CREATOR, string(b, CREATOR)); + put(KEY_PROJECT, string(b, PROJECT)); + put(KEY_COPYRIGHT, string(b, COPYRIGHT)); + put(KEY_CREATION_TIME, string(b, CREATION_TIME)); + put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string())); + put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string())); + // The industry header is there when the generic header says so and the file holds it. + let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN; + if industry { + put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string())); + put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string())); + put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code)); + } + text +} + +/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD. +fn format_time_code(v: u32) -> String { + let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF); + if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") } +} + +/// The inverse of [`format_time_code`]; `None` for anything else. +fn parse_time_code(s: &str) -> Option { + if let Some(hex) = s.strip_prefix("0x") { + return u32::from_str_radix(hex, 16).ok(); + } + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) { + return None; + } + parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0'))) +} + +/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise. +pub(crate) fn bits_for(opt: Option, sample: SampleType) -> u8 { + match opt { + Some(b) => b.bits(), + None if sample == SampleType::U8 => 8, + None => 16, + } +} + +/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16. +pub(crate) fn plan_sample(opt: Option, src: SampleType) -> SampleType { + match opt { + Some(DpxBits::Bits8) => SampleType::U8, + Some(_) => SampleType::U16, + None if src == SampleType::U8 => SampleType::U8, + None => SampleType::U16, + } +} + +struct Writer { + h: Vec, +} + +impl Writer { + fn u8(&mut self, at: usize, v: u8) { + if let Some(b) = self.h.get_mut(at) { + *b = v; + } + } + + fn u16(&mut self, at: usize, v: u16) { + if let Some(s) = self.h.get_mut(at..at + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + fn u32(&mut self, at: usize, v: u32) { + if let Some(s) = self.h.get_mut(at..at + 4) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + /// Marks a run of numeric fields undefined (all ones). + fn undefined(&mut self, from: usize, to: usize) { + if let Some(s) = self.h.get_mut(from..to) { + s.fill(0xFF); + } + } + + /// An ASCII string field, cut to leave room for a terminating NUL. + fn string(&mut self, (at, len): (usize, usize), v: &str) { + let ascii: Vec = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect(); + if let Some(s) = self.h.get_mut(at..at + ascii.len()) { + s.copy_from_slice(&ascii); + } + } +} + +fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> { + img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty()) +} + +/// Appends one line's code values, padded to a 32-bit word. +fn pack_line(out: &mut Vec, codes: &[u16], bits: u8) { + let start = out.len(); + match bits { + 8 => out.extend(codes.iter().map(|&c| c as u8)), + 10 => { + for chunk in codes.chunks(3) { + let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32))); + out.extend_from_slice(&word.to_be_bytes()); + } + } + 12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())), + _ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())), + } + let written = out.len() - start; + out.resize(start + written.div_ceil(4) * 4, 0); +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let (descriptor, layout) = match img.layout() { + ChannelLayout::Gray => (6u8, ChannelLayout::Gray), + ChannelLayout::Rgb => (50, ChannelLayout::Rgb), + ChannelLayout::Rgba => (51, ChannelLayout::Rgba), + l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))), + }; + let bits = bits_for(opts.dpx_bits, img.sample_type()); + let codes: Vec = match (img.sample_type(), bits) { + (SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(), + (SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(), + (SampleType::U16, 10 | 12) => { + let max = (1u64 << bits) - 1; + img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect() + } + (s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))), + }; + let per_line = w as usize * layout.channels(); + if codes.len() != per_line * h as usize { + return Err(CodecError::encode(F, "the image has the wrong number of samples")); + } + let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 }) + .and_then(|l| l.checked_mul(h as usize)) + .ok_or_else(|| CodecError::encode(F, "image too large"))?; + let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?; + + let mut wr = Writer { h: vec![0; HEADER_LEN] }; + // Generic file header. + wr.h[..4].copy_from_slice(b"SDPX"); + wr.u32(4, HEADER_LEN as u32); + wr.string((8, 8), "V2.0"); + wr.u32(16, total); + wr.u32(20, 1); + wr.u32(24, GENERIC_LEN); + wr.u32(28, INDUSTRY_LEN); + wr.u32(32, 0); + let keep = |key| if opts.embed_metadata { text(&img, key) } else { None }; + wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or("")); + wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft")); + wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or("")); + wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or("")); + wr.u32(660, UNDEF32); + // Image information: default orientation, one element. + wr.u16(ORIENTATION, 0); + wr.u16(ORIENTATION + 2, 1); + wr.u32(772, w); + wr.u32(776, h); + let e = ELEMENT0; + wr.u32(e, 0); + wr.u32(e + 4, 0); + wr.u32(e + 8, UNDEF32); + wr.u32(e + 12, ((1u32 << bits) - 1).min(65535)); + wr.u32(e + 16, UNDEF32); + wr.u8(e + 20, descriptor); + // No claim about the encoding unless the document came with one. + let code = |key| keep(key).and_then(|v| v.parse::().ok()).unwrap_or(UNDEF8); + wr.u8(e + 21, code(KEY_TRANSFER)); + wr.u8(e + 22, code(KEY_COLORIMETRIC)); + wr.u8(e + 23, bits); + wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 }); + wr.u16(e + 26, 0); + wr.u32(e + 28, HEADER_LEN as u32); + wr.u32(e + 32, 0); + wr.u32(e + 36, 0); + // The seven unused elements, the image source header's numbers and the industry header's + // numbers are undefined; their strings stay empty. + wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS)); + wr.undefined(1408, 1432); + wr.undefined(1620, 1644); + wr.undefined(FRAME_POSITION, 1732); + wr.undefined(TIME_CODE, 1972); + if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::().ok()) { + wr.u32(FRAME_POSITION, v); + } + if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { + wr.u32(FRAME_RATE, v.to_bits()); + } + if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) { + wr.u32(TIME_CODE, v); + } + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, bits); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal header for `w`×`h` with element 0 set; image data at 2048. + fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec { + let mut b = vec![0u8; HEADER_LEN]; + let put32 = |b: &mut Vec, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + let put16 = |b: &mut Vec, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" }); + put32(&mut b, 4, HEADER_LEN as u32); + put32(&mut b, 28, INDUSTRY_LEN); + put16(&mut b, ORIENTATION, 0); + put16(&mut b, ORIENTATION + 2, 1); + put32(&mut b, 772, w); + put32(&mut b, 776, h); + b[ELEMENT0 + 20] = descriptor; + b[ELEMENT0 + 21] = UNDEF8; + b[ELEMENT0 + 22] = UNDEF8; + b[ELEMENT0 + 23] = bits; + put16(&mut b, ELEMENT0 + 24, packing); + put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32); + b[FRAME_POSITION..1732].fill(0xFF); + b[TIME_CODE..1972].fill(0xFF); + b + } + + fn words(o: Order, ws: &[u32]) -> Vec { + ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect() + } + + #[test] + fn ten_bit_method_a_and_b_unpack_three_to_a_word() { + for o in [Order::Big, Order::Little] { + for (packing, base) in [(1u16, 22u32), (2, 20)] { + let mut b = header(o, 1, 1, 50, 10, packing); + b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))])); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}"); + } + } + } + + #[test] + fn ten_bit_packing_0_is_one_bit_stream_per_line() { + // Four luma samples: 40 bits over two words, the line padded to the second word. + let samples = [1u64, 2, 1022, 1023]; + let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24; + for o in [Order::Big, Order::Little] { + let mut b = header(o, 4, 2, 6, 10, 0); + let line = words(o, &[(stream >> 32) as u32, stream as u32]); + b.extend(&line); + b.extend(&line); + let img = decode(&b, &Limits::default()).unwrap(); + let want: Vec = samples.iter().map(|&s| widen(s as u16, 1023)).collect(); + assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}"); + } + } + + #[test] + fn twelve_bit_method_a_keeps_the_top_bits() { + let mut b = header(Order::Big, 2, 1, 6, 12, 1); + b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]); + } + + #[test] + fn widen_and_narrow_round_trip_every_code() { + for bits in [10u8, 12] { + let max = (1u64 << bits) - 1; + for c in 0..=max as u16 { + assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}"); + } + } + assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16); + } + + #[test] + fn orientation_codes_map_to_exif() { + let mut b = header(Order::Big, 1, 1, 6, 8, 0); + for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] { + b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes()); + assert_eq!(orientation(&b), exif, "DPX {dpx}"); + } + } + + #[test] + fn time_codes_round_trip() { + assert_eq!(format_time_code(0x0102_0304), "01:02:03:04"); + assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304)); + assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000)); + assert_eq!(parse_time_code("1:2:3:4"), None); + } + + #[test] + fn eight_bit_lines_without_word_padding_still_open() { + // 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8. + let mut b = header(Order::Big, 1, 2, 50, 8, 0); + b.extend_from_slice(&[1, 2, 3, 4, 5, 6]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 22845d930b..2a1407d1c4 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod deep_exr; +pub(crate) mod dpx; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index 720f9421a2..d5654d27a0 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch layouts[0] } -pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan { +pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan { let c = caps(format); let (layout, sample) = (image.layout(), image.sample_type()); match format { // PNM: integer data can use any layout (PAM); float goes to PFM // which only has gray and RGB. Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 }, + // DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped. + // The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16). + Format::Dpx => Plan { + layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, + sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), + }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if m.dpi.is_some() && !c.dpi { w.push(W::DpiDropped); } - if !m.text.is_empty() && !c.text { + // DPX keeps its own header fields, not free-form text. + let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k)); + if m.text.iter().any(|(k, _)| !text_kept(k)) { w.push(W::TextDropped); } } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ddb7587141..4a76c14458 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -27,6 +27,8 @@ pub enum Format { Heif, /// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data. Fits, + /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. + Dpx, } /// What a format can hold **and** what this crate reads/writes for it. @@ -84,7 +86,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 16] = [ + pub const ALL: [Format; 17] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +103,7 @@ impl Format { Format::Avif, Format::Heif, Format::Fits, + Format::Dpx, ]; pub fn caps(self) -> FormatCaps { @@ -131,6 +134,7 @@ impl Format { Format::Avif => "AVIF", Format::Heif => "HEIF", Format::Fits => "FITS", + Format::Dpx => "DPX", } } @@ -154,6 +158,7 @@ impl Format { Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], + Format::Dpx => &["dpx"], } } @@ -175,6 +180,7 @@ impl Format { Format::Avif => "image/avif", Format::Heif => "image/heif", Format::Fits => "image/fits", + Format::Dpx => "image/x-dpx", } } @@ -226,6 +232,8 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, + // `text`: the DPX header fields only (see `fidelity_warnings_with`). + Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, } } @@ -274,6 +282,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() { return Some(Format::Pnm); } + if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { + return Some(Format::Dpx); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 92d8fb5823..c1f9d7b75f 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -156,6 +156,32 @@ pub enum DecodeWarning { /// Deep image data (per-pixel sample lists) was composited into the flat image; the samples /// themselves (Z and other deep channels) are not part of the flat result. DeepFlattened { format: Format, max_samples_per_pixel: u32 }, + /// Only the first image element of a DPX file was decoded; `total` counts them all. + MoreElements { total: u32 }, + /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, + /// 255 when undefined). The code values are imported as stored, without a colour transform. + DpxEncoding { transfer: u8, colorimetric: u8 }, +} + +/// The SMPTE 268M name of a transfer characteristic or colorimetric code. +fn dpx_code_name(code: u8) -> &'static str { + match code { + 0 => "user-defined", + 1 => "printing density", + 2 => "linear", + 3 => "logarithmic", + 4 => "unspecified video", + 5 => "SMPTE 274M", + 6 => "ITU-R BT.709", + 7 => "ITU-R BT.601 625-line", + 8 => "ITU-R BT.601 525-line", + 9 => "NTSC composite video", + 10 => "PAL composite video", + 11 => "Z linear", + 12 => "Z homogeneous", + 255 => "undefined", + _ => "unknown", + } } impl fmt::Display for DecodeWarning { @@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning { "{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept", format = format.name() ), + DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"), + DecodeWarning::DpxEncoding { transfer, colorimetric } => write!( + f, + "DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform", + dpx_code_name(*transfer), + dpx_code_name(*colorimetric) + ), } } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 16025a5a46..e90b5235c4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -37,13 +37,13 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), Format::Fits => fits::decode(bytes, l), + Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -93,6 +94,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res let o = match format { Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, + Format::Dpx => dpx::orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -224,6 +226,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), + Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 9337f1e6f1..b955e42d3f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -121,6 +121,38 @@ pub enum ExrCompression { Piz, } +/// Bits per sample of a DPX file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpxBits { + Bits8, + Bits10, + Bits12, + Bits16, +} + +impl DpxBits { + /// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`. + pub fn from_bits(bits: u64) -> Option { + match bits { + 8 => Some(DpxBits::Bits8), + 10 => Some(DpxBits::Bits10), + 12 => Some(DpxBits::Bits12), + 16 => Some(DpxBits::Bits16), + _ => None, + } + } + + /// The bits per sample. + pub fn bits(self) -> u8 { + match self { + DpxBits::Bits8 => 8, + DpxBits::Bits10 => 10, + DpxBits::Bits12 => 12, + DpxBits::Bits16 => 16, + } + } +} + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -141,6 +173,8 @@ pub struct EncodeOptions { /// only when it could pass the 4 GiB a classic TIFF can address. pub tiff_bigtiff: bool, pub exr_compression: ExrCompression, + /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. + pub dpx_bits: Option, /// Embed the ICC profile when the format supports it. pub embed_icc: bool, /// Embed EXIF/XMP/DPI/text when the format supports it. @@ -159,6 +193,7 @@ impl Default for EncodeOptions { tiff_compression: TiffCompression::Deflate, tiff_bigtiff: false, exr_compression: ExrCompression::Zip16, + dpx_bits: None, embed_icc: true, embed_metadata: true, } diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 367a8bd813..22bf227eb8 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() { Format::OpenExr, Format::Hdr, Format::Fits, + Format::Dpx, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/dpx.rs new file mode 100644 index 0000000000..8fdeb398c3 --- /dev/null +++ b/crates/codecs/tests/dpx.rs @@ -0,0 +1,386 @@ +//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec: +//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept +//! through a round trip, the bit-depth choice on export and the files it refuses (#1049). + +use photocraft_codecs::*; + +const HEADER: usize = 2048; +const ELEMENT0: usize = 780; + +/// A DPX file built field by field. +#[derive(Clone)] +struct Fixture { + big: bool, + width: u32, + height: u32, + descriptor: u8, + bits: u8, + packing: u16, + encoding: u16, + signed: bool, + transfer: u8, + colorimetric: u8, + orientation: u16, + elements: u16, + strings: Vec<((usize, usize), &'static str)>, + frame_position: u32, + frame_rate: f32, + time_code: u32, + /// Code values, interleaved, top line first. + codes: Vec, +} + +impl Fixture { + fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self { + let channels = channels(descriptor); + let max = (1u32 << bits) - 1; + // Spread over the whole range, ends included. + let n = (width * height) as usize * channels; + let codes = (0..n) + .map(|i| { + if i == 0 { + 0 + } else if i == 1 { + max as u16 + } else { + ((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16 + } + }) + .collect(); + Fixture { + big: true, + width, + height, + descriptor, + bits, + packing, + encoding: 0, + signed: false, + transfer: 255, + colorimetric: 255, + orientation: 0, + elements: 1, + strings: Vec::new(), + frame_position: u32::MAX, + frame_rate: f32::from_bits(u32::MAX), + time_code: u32::MAX, + codes, + } + } + + fn u16(&self, v: u16) -> [u8; 2] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn u32(&self, v: u32) -> [u8; 4] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn line(&self, codes: &[u16]) -> Vec { + let mut out = Vec::new(); + match (self.bits, self.packing) { + (8, _) => out.extend(codes.iter().map(|&c| c as u8)), + (16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))), + (10, 1 | 2) => { + let base = if self.packing == 1 { 22 } else { 20 }; + for chunk in codes.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32))); + out.extend(self.u32(w)); + } + } + (12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))), + _ => { + // One bit stream, high bits first, cut into 32-bit words. + let mut bits: Vec = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect(); + bits.resize(bits.len().div_ceil(32) * 32, false); + for word in bits.chunks(32) { + out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b)))); + } + } + } + out.resize(out.len().div_ceil(4) * 4, 0); + out + } + + fn bytes(&self) -> Vec { + let mut b = vec![0u8; HEADER]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, if self.big { b"SDPX" } else { b"XPDS" }); + put(4, &self.u32(HEADER as u32)); + put(8, b"V2.0"); + put(24, &self.u32(1664)); + put(28, &self.u32(384)); + put(768, &self.u16(self.orientation)); + put(770, &self.u16(self.elements)); + put(772, &self.u32(self.width)); + put(776, &self.u32(self.height)); + for el in 0..usize::from(self.elements.min(8)) { + let e = ELEMENT0 + el * 72; + put(e, &self.u32(u32::from(self.signed))); + put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]); + put(e + 24, &self.u16(self.packing)); + put(e + 26, &self.u16(self.encoding)); + put(e + 28, &self.u32(HEADER as u32)); + } + for &((at, len), s) in &self.strings { + assert!(s.len() < len); + put(at, s.as_bytes()); + } + put(1712, &self.u32(self.frame_position)); + put(1724, &self.u32(self.frame_rate.to_bits())); + put(1920, &self.u32(self.time_code)); + let per_line = self.width as usize * channels(self.descriptor); + for line in self.codes.chunks(per_line) { + b.extend(self.line(line)); + } + b + } +} + +fn channels(descriptor: u8) -> usize { + match descriptor { + 6 => 1, + 50 => 3, + _ => 4, + } +} + +fn layout(descriptor: u8) -> ChannelLayout { + match descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } +} + +/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`. +fn expected(c: u16, bits: u8) -> u16 { + (f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16 +} + +/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12). +fn written_codes(b: &[u8]) -> (u8, u16, Vec) { + let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]); + let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); + let (w, h) = (be32(772) as usize, be32(776) as usize); + let nc = channels(b[ELEMENT0 + 20]); + let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24)); + let off = be32(ELEMENT0 + 28) as usize; + let per_line = w * nc; + let stride = match bits { + 10 => per_line.div_ceil(3) * 4, + _ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4, + }; + let mut codes = Vec::new(); + for y in 0..h { + let line = &b[off + y * stride..]; + for i in 0..per_line { + codes.push(match bits { + 8 => u16::from(line[i]), + 10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16, + 12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4, + _ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]), + }); + } + } + (bits, packing, codes) +} + +fn be32_at(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +fn opts(bits: Option) -> EncodeOptions { + EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() } +} + +const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)]; + +#[test] +fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() { + for descriptor in [6, 50, 51] { + for (bits, packing) in COMBOS { + // Widths that leave partly filled words at the end of a line. + let fx = Fixture::new(descriptor, bits, packing, 5, 3); + let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}")); + let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}"); + assert_eq!(detect(&fx.bytes()), Some(Format::Dpx)); + assert_eq!((img.width(), img.height()), (5, 3), "{what}"); + assert_eq!(img.layout(), layout(descriptor), "{what}"); + assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}"); + match bits { + 8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::>(), "{what}"), + 16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"), + _ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::>(), "{what}"), + } + } + } +} + +#[test] +fn big_and_little_endian_files_open_identically() { + for (bits, packing) in COMBOS { + let be = Fixture::new(50, bits, packing, 4, 2); + let le = Fixture { big: false, ..be.clone() }; + assert_eq!(&le.bytes()[..4], b"XPDS"); + assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}"); + } +} + +#[test] +fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() { + let mut fx = Fixture::new(6, 10, 1, 1, 1); + fx.codes = vec![512]; + assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]); +} + +#[test] +fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() { + for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] { + for packing in [0, 1] { + let fx = Fixture::new(50, bits, packing, 7, 3); + let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap(); + let (wbits, wpacking, codes) = written_codes(&out); + assert_eq!((wbits, wpacking), (bits, 1)); + assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}"); + } + } +} + +#[test] +fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() { + let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) }; + let img = decode(&fx.bytes()).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(&out[..4], b"SDPX"); + let (bits, packing, codes) = written_codes(&out); + assert_eq!((bits, packing, codes), (16, 0, fx.codes)); + assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples()); +} + +#[test] +fn log_frames_report_their_encoding_and_keep_the_plain_mapping() { + let mut fx = Fixture::new(50, 10, 1, 3, 2); + (fx.transfer, fx.colorimetric) = (3, 1); + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]); + let note = img.warnings[0].to_string(); + assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}"); + assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::>()); +} + +#[test] +fn only_the_first_of_several_image_elements_is_imported() { + let mut fx = Fixture::new(6, 8, 0, 2, 2); + fx.elements = 2; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings.iter().map(ToString::to_string).collect::>(), ["only the first of 2 image elements was imported"]); +} + +#[test] +fn listed_header_fields_survive_a_round_trip() { + let mut fx = Fixture::new(50, 10, 1, 2, 2); + fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")]; + (fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304); + let img = decode(&fx.bytes()).unwrap(); + for (k, v) in [ + ("DPX:Creator", "Resolve 20.1"), + ("DPX:Project", "Reel 7"), + ("DPX:Copyright", "(c) 2026 Studio"), + ("DPX:CreationTime", "2026:10:12:09:30:00UTC"), + ("DPX:Transfer", "3"), + ("DPX:Colorimetric", "1"), + ("DPX:FramePosition", "86400"), + ("DPX:FrameRate", "23.976"), + ("DPX:TimeCode", "01:02:03:04"), + ] { + assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text); + } + let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap(); + let src = fx.bytes(); + // Field by field: the strings, the element's encoding codes, film and TV fields. + for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] { + assert_eq!(out[at..at + len], src[at..at + len], "field at {at}"); + } + assert_eq!(decode(&out).unwrap().meta.text, img.meta.text); +} + +#[test] +fn chosen_bit_depth_sets_the_header_and_packing() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::>()).unwrap(); + for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] { + let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap(); + assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}"); + let back = decode(&out).unwrap(); + assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb)); + } + // Without a choice the depth follows the image. + let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap(); + assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8); + assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + let float = img.convert(ChannelLayout::Rgb, SampleType::F32); + assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); +} + +#[test] +fn gray_with_alpha_is_written_as_luma_with_a_warning() { + let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(out[ELEMENT0 + 20], 6); + assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded)); +} + +#[test] +fn only_free_form_text_is_reported_as_dropped() { + let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap(); + img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())]; + assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); + img.meta.text.push(("Comment".into(), "from a PNG".into())); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); +} + +#[test] +fn stored_orientation_is_turned_upright() { + // Orientation 4: the stored lines are the image's columns, left to right. + let mut fx = Fixture::new(6, 8, 0, 3, 1); + fx.codes = vec![1, 2, 3]; + fx.orientation = 4; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..])); +} + +#[test] +fn unsupported_and_damaged_files_error_instead_of_panicking() { + let unsupported = |bytes: Vec, what: &str| { + assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}"); + }; + unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor"); + unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding"); + unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples"); + let mut float = Fixture::new(50, 16, 0, 2, 2).bytes(); + float[ELEMENT0 + 23] = 32; + unsupported(float, "32-bit float"); + let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes(); + method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes()); + unsupported(method_b, "12-bit method B"); + + let good = Fixture::new(51, 10, 1, 9, 9).bytes(); + let mut no_elements = good.clone(); + no_elements[770..772].copy_from_slice(&0u16.to_be_bytes()); + let mut past_end = good.clone(); + past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes()); + let mut huge = good.clone(); + huge[772..780].copy_from_slice(&[0xFF; 8]); + for (what, bytes) in [ + ("header cut short", good[..900].to_vec()), + ("truncated element", good[..good.len() - 1].to_vec()), + ("no image elements", no_elements), + ("data offset past the end", past_end), + ("absurd size", huge), + ("magic alone", b"SDPX".to_vec()), + ] { + assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); + } +} diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/malformed.rs index fea7aedf3a..b6da84b072 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/malformed.rs @@ -60,7 +60,7 @@ fn truncated_files_mostly_error() { // Chopping off the second half must not silently succeed for formats // with length-checked rasters. for (f, bytes) in samples() { - if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { + if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight()); assert!(r.is_err(), "{f:?} decoded half a file"); } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 742de21598..4cb50c0057 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -134,6 +134,9 @@ mod pnm { mod fits { cases!(Format::Fits); } +mod dpx { + cases!(Format::Dpx); +} mod qoi { cases!(Format::Qoi); } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 74069c8b8c..2757df5696 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -118,7 +118,7 @@ pub(crate) fn list_images(dir: &str) -> Result> { #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", - "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", + "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone @@ -229,6 +229,8 @@ pub(crate) struct SaveOpts { /// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or /// transparency, else 24. pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. + pub dpx_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -238,7 +240,7 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } } } @@ -253,6 +255,7 @@ impl SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, + dpx_bits: dpx_bits_param(p, cmd)?, image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -284,6 +287,19 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("dpxBits") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(photocraft_io::DpxBits::from_bits) + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`dpxBits` must be 8, 10, 12 or 16, got {v}") }), + } +} + /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; @@ -298,6 +314,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); + opts.encode.dpx_bits = save.dpx_bits; if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -1405,7 +1422,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1468,7 +1485,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?,"tiffLayers":bool=false,"tgaBits":24|32?,"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],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"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 ), @@ -1477,7 +1494,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?,"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],"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 ), @@ -1505,7 +1522,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 8cab1ce635..737b14ad8d 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -155,6 +155,32 @@ fn save_a_copy_writes_the_targa_bits_asked_for() { } } +/// `dpxBits` picks a DPX's bits per sample (byte 803 of the header); unset, it follows the +/// document's depth. Anything but 8, 10, 12 or 16 is refused before anything is written. +#[test] +fn save_a_copy_writes_the_dpx_bits_asked_for() { + let dir = tmp("dpx-bits"); + for (depth, params, bits) in + [(8, json!({}), 8), (16, json!({}), 16), (16, json!({"dpxBits": 10}), 10), (8, json!({"dpxBits": 12}), 12), (16, json!({"dpxBits": null}), 16)] + { + let mut s = session(12, 6, depth); + let out = join(&dir, "a.dpx"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], bits), "{depth}-bit {params}"); + assert_eq!(photocraft_io::import("a.dpx", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + for bad in [json!(24), json!(0), json!("10"), json!(10.5)] { + let out = join(&dir, "bad.dpx"); + let r = s.execute("file.saveACopy", json!({"path": out, "dpxBits": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + } +} + /// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written. #[test] fn bad_targa_bits_are_refused_before_writing() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6d33c332a0..0a1577714d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -61,6 +61,7 @@ pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 48d6944c72..35a5e5ad46 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -53,6 +53,9 @@ codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0); codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); +codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); +codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); +codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; @@ -111,6 +114,25 @@ fn fits_header_cards_round_trip_as_text() { } } +/// DPX header fields travel as the document's text metadata and come back on a DPX export at the +/// chosen bit depth; the transfer characteristic is reported when the frame is opened (#1049). +#[test] +fn dpx_header_fields_and_encoding_notes_round_trip_through_the_document() { + let mut d = single(ColorMode::Rgb, SampleType::U16, false); + d.metadata.text = vec![("DPX:Project".into(), "Reel 7".into()), ("DPX:Transfer".into(), "3".into()), ("DPX:TimeCode".into(), "01:00:00:12".into())]; + let mut opts = ExportOptions::default(); + opts.encode.dpx_bits = Some(DpxBits::Bits10); + let r = export(&d, "a.dpx", &opts).unwrap(); + assert!(r.bytes.starts_with(b"SDPX")); + assert_eq!(r.bytes[803], 10, "bits per sample"); + let back = import("a.dpx", &r.bytes).unwrap(); + for kv in &d.metadata.text { + assert!(back.document.metadata.text.contains(kv), "{kv:?}: {:?}", back.document.metadata.text); + } + assert!(back.warnings.iter().any(|w| w.contains("transfer characteristic: logarithmic (3)")), "{:?}", back.warnings); + assert_eq!(back.document.depth, SampleType::U16); +} + /// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is /// linearised on export and comes back tagged linear sRGB with the same colours. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d6e3bef284..0aa2984e23 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-12 · **Change:** minor (Photoshop Actions `.atn` 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-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` 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 @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 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) | diff --git a/docs/gaps.md b/docs/gaps.md index 01e54f1f7c..d505e8f447 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-12 · **Change:** minor (G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **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 @@ -195,7 +195,7 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). @@ -238,6 +238,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 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-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | From 46740ad4bcaee6c2018ec9f99e4db9dd194dc3aa Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 01:47:14 +0200 Subject: [PATCH 002/223] Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347) * Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0 (outside sRGB) came back as 100/28/0/17 when a CMYK document was filled. tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends the entered inks, and Edit > Fill (and the fill keys) write them as they are into a CMYK target. Fill also enters the document's CMYK profile. Co-Authored-By: Claude Opus 5.5 * Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 2 ++ crates/engine/src/brush_cmds/tests.rs | 18 ++++++++++++++++++ crates/paint/src/brush.rs | 6 ++++++ crates/paint/src/render.rs | 8 +++++++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index abc6b49d96..321973b8d2 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -204,6 +204,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result b.smoothing.amount = v.clamp(0.0, 1.0); } b.color = color(p.get("color"), s.tools.foreground); + b.color_cmyk = s.tools.picked_cmyk([b.color[0], b.color[1], b.color[2]]).map(|k| ([b.color[0], b.color[1], b.color[2]], k)); b.background = s.tools.background; b.erase = flag(p, "erase", false); b.seed = match p.get("seed").and_then(Value::as_u64) { @@ -367,6 +368,7 @@ fn block_brush(b: BrushSettings, p: &Value) -> Result { pressure_opacity: false, spacing: 0.25, color: b.color, + color_cmyk: b.color_cmyk, background: b.background, erase: b.erase, smoothing: b.smoothing, diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 3a04a470fb..8d718c13c7 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -883,3 +883,21 @@ fn clear_mode_erases_with_the_brush_or_pencil() { assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}"); } } + +/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by +/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17). +#[test] +fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + for cmd in ["paint.stroke", "paint.pencil"] { + let mut s = session(16, 16); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + s.execute(cmd, json!({"points": [[8.0, 8.0]], "size": 6.0, "hardness": 1.0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let got: Vec = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect(); + assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}"); + } +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 51d8026f31..bdef893fde 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -486,6 +486,11 @@ pub struct BrushSettings { pub pressure_opacity: bool, /// Straight RGBA colour (display RGB). Commands fill it from the foreground colour. pub color: [f32; 4], + /// The inks `color` was picked as (its sRGB colour, the CMYK values), for a CMYK target: the + /// stroke paints exactly these, since an sRGB round trip does not give them back (#4344). + /// Only valid while `color` is that sRGB colour; ignored with Color Dynamics. + #[serde(skip)] + pub color_cmyk: Option<([f32; 3], [f32; 4])>, /// Background colour for Color Dynamics' foreground/background jitter. pub background: [f32; 4], /// Erase instead of paint. @@ -545,6 +550,7 @@ impl Default for BrushSettings { pressure_size: true, pressure_opacity: false, color: [0.0, 0.0, 0.0, 1.0], + color_cmyk: None, background: [1.0, 1.0, 1.0, 1.0], erase: false, mode: BlendMode::Normal, diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 688232c93e..34c296fb0e 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use photocraft_color::{BlendMode, PixelFormat, SampleType}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -966,6 +966,12 @@ impl StrokeRenderer { let opacity = b.opacity.clamp(0.0, 1.0); let mut src = [0.0f32; 8]; from_rgba_into(&fmt, b.color, &mut src); + // A colour picked as CMYK paints its inks as they are in a CMYK target (#4344). + if let (ColorMode::Cmyk, Some((rgb, inks))) = (fmt.mode, b.color_cmyk) + && rgb == [b.color[0], b.color[1], b.color[2]] + { + src[..4].copy_from_slice(&inks); + } if let Some(a) = a_idx { src[a] = b.color[3]; } From dc8a3f0b353760c9a5509f222f3ee534de71c98f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 01:54:16 +0200 Subject: [PATCH 003/223] Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352) * Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0 (outside sRGB) came back as 100/28/0/17 when a CMYK document was filled. tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends the entered inks, and Edit > Fill (and the fill keys) write them as they are into a CMYK target. Fill also enters the document's CMYK profile. Co-Authored-By: Claude Opus 5.5 * Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344) Co-Authored-By: Claude Sonnet 5.5 * Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/src/paint.rs | 54 +++++++++++++++++++++++++++++++-- crates/engine/src/extra_cmds.rs | 4 ++- crates/engine/src/fill_cmds.rs | 8 ++++- crates/engine/src/paint_cmds.rs | 32 +++++++++++++++++-- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index 9376102efe..c32f28c648 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -63,6 +63,7 @@ fn composite_area( coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, + inks: Option<[f32; 4]>, ) { // Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched, // so their copy-on-write storage (and GPU residency) survives. @@ -93,6 +94,14 @@ fn composite_area( o[3] = d[3]; } from_rgba_into(&fmt, o, &mut enc); + // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. + if let Some(i) = inks + && fmt.mode == photocraft_color::ColorMode::Cmyk + && blend_mode == BlendMode::Normal + && k >= 1.0 + { + enc[..4].copy_from_slice(&i); + } px.copy_from_slice(&enc[..n]); } } @@ -144,6 +153,7 @@ pub fn paint_gradient( c }, lock_transparency, + None, ); } @@ -179,6 +189,25 @@ pub fn bucket_fill_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) -> bool { + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) +} + +/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +#[allow(clippy::too_many_arguments)] +pub fn bucket_fill_inks( + s: &mut Surface, + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -186,7 +215,7 @@ pub fn bucket_fill_src( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); true } @@ -199,10 +228,31 @@ pub fn bucket_fill_region_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) { + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); +} + +/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +pub fn bucket_fill_region_inks( + s: &mut Surface, + region: &crate::selection::Region, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency); + composite_area( + s, + b, + BlendMode::Normal, + |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), + src, + lock_transparency, + inks, + ); } #[cfg(test)] diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1399784b1d..c4fdebf214 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -136,6 +136,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; let color = color_param(p, "color", s.tools.foreground); + // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). + let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { None | Some(Value::Null) => photocraft_color::BlendMode::Normal, Some(Value::String(m)) => crate::commands::blend_from_str(m) @@ -214,7 +216,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { - crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?; + crate::fill_cmds::blend_color_mask(surf, area, color, picked, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index d99ebd9a55..30c3ea1dd2 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -215,16 +215,22 @@ pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: O /// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band /// is already a grayscale coverage mask; opacity, blend modes and alpha locking /// must behave identically for both commands at 8/16/32-bit depth. +#[allow(clippy::too_many_arguments)] pub(crate) fn blend_color_mask( surf: &mut Surface, area: Rect, color: [f32; 4], + picked: Option<[f32; 4]>, coverage: &Surface, mode: BlendMode, opacity: f32, preserve_transparency: bool, ) -> Result<()> { - blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) + let source = match picked { + Some(cmyk) if color[3] >= 1.0 => Source::Cmyk { rgba: color, cmyk }, + _ => Source::Color(color), + }; + blend_into(surf, area, &source, Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) } /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index ad16cd85d9..d97ca5b319 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient}; +use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -61,6 +61,8 @@ fn bucket(s: &mut Session, p: &Value) -> Result { } else { None }; + // A foreground picked as CMYK fills a CMYK document with the picked inks (#4196). + let inks = s.tools.picked_cmyk([c[0], c[1], c[2]]).filter(|_| c[3] >= 1.0 && pattern.is_none()); let filled = s.edit("Paint Bucket", |doc, active| { let area = doc.bounds(); let sel = doc.selection.clone(); @@ -80,10 +82,10 @@ fn bucket(s: &mut Session, p: &Value) -> Result { bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src) } } else if let Some(region) = ®ion { - bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks); true } else { - bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks) }; surf.prune(); Ok(ok) @@ -330,6 +332,30 @@ mod tests { assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); } + /// #4196: the Paint Bucket and Edit > Stroke write a CMYK pick as its inks into a CMYK document, + /// not as its sRGB stand-in converted back (100/0/0/0 cyan would come out as about 100/28/0/17). + #[test] + fn bucket_and_stroke_write_a_cmyk_pick_as_its_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + let mut s = session(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + let inks_at = |s: &Session, x: i32, y: i32| -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(Rect::new(x, y, x + 1, y + 1)).into_iter().take(4).collect() + }; + let is_cyan = |got: &[f32]| got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02); + s.execute("paint.bucket", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + let got = inks_at(&s, 5, 5); + assert!(is_cyan(&got), "bucket: {got:?}"); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 2, "location": "inside", "color": rgb})).unwrap(); + let got = inks_at(&s, 0, 5); + assert!(is_cyan(&got), "stroke: {got:?}"); + } + #[test] fn bucket_fills_with_a_pattern() { // Paint Bucket with contents="pattern" flood-fills the region with a pattern, not a colour. From 97448b27f39770cf8ca256e9047d43adc42e10b8 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:54:50 +0300 Subject: [PATCH 004/223] perf(raster): moved fills keep sharing one copy-on-write tile (#4414) Surface::translated copied every target tile of a move that isn't a whole number of tiles, so a solid fill (one shared tile) became one unique tile per coordinate after its first nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and history kept them all. A target tile whose window reads only tiles holding one single pixel value (each source tile is scanned once per call; one tile is kept per value) now shares that tile. Fully covered targets also skip the default-pixel pre-fill they are about to overwrite. 12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles 192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged (the test compares every depth with the copied pixels, and checks that a shared but non-uniform tile is still copied). Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/raster/src/lib.rs | 126 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dbb6c0a107..74a5870123 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -90,6 +90,49 @@ pub struct Surface { tiles: BTreeMap>, } +/// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned +/// once, and one tile kept per value so the targets share it. +#[derive(Default)] +struct UniformTiles { + /// Per source tile (by address): the index of its value's tile in `canonical`, or `None`. + of: std::collections::HashMap<*const Tile, Option>, + canonical: Vec>, +} + +impl UniformTiles { + /// The tile to share for a target reading window `r` of `s`, when every tile under it holds + /// one and the same pixel value (`r` must lie on existing tiles). + fn window(&mut self, s: &Surface, r: Rect) -> Option> { + let bpp = s.format.bytes_per_pixel(); + let mut found = None; + for c in r.tiles() { + let t = s.tiles.get(&c)?; + let k = self.value_of(t, bpp)?; + if found.is_some_and(|f| f != k) { + return None; + } + found = Some(k); + } + self.canonical.get(found?).cloned() + } + + fn value_of(&mut self, t: &Arc, bpp: usize) -> Option { + if let Some(k) = self.of.get(&Arc::as_ptr(t)) { + return *k; + } + let mut px = t.data.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } + }); + self.of.insert(Arc::as_ptr(t), k); + k + } +} + impl PartialEq for Surface { fn eq(&self, other: &Self) -> bool { self.format == other.format && self.default_pixel == other.default_pixel && self.tiles == other.tiles @@ -693,7 +736,9 @@ impl Surface { } /// The surface moved by whole pixels: its encoded samples copied tile to tile (no float - /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves. + /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves, and + /// a target tile read entirely from tiles of one single pixel value shares one such tile (the + /// inside of a solid fill stays one copy-on-write tile however often it moves). /// `content` must hold every pixel that isn't the default one (`content_bounds`, say): only /// tiles it reaches are kept. pub fn translated(&self, dx: i32, dy: i32, content: Rect) -> Surface { @@ -707,10 +752,24 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); 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]; for dc in targets { - let mut tile = Tile::filled(&self.format, &self.default_pixel); let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); + // Every pixel of the target comes from a source tile (none from the default pixel). + // `sr` must be the exact preimage: an offset of `i32::MIN` saturates its negation. + let covered = sr.width() == TILE_SIZE.unsigned_abs() + && sr.height() == TILE_SIZE.unsigned_abs() + && sr.translate(dx, dy) == dr + && content.contains_rect(&sr) + && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); + if covered && let Some(shared) = uniform.window(self, sr) { + out.tiles.insert(dc, shared); + 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); @@ -1210,6 +1269,69 @@ mod tests { padded.fill_rect(Rect::new(2000, 2000, 2100, 2100), &s.default_pixel()); assert_eq!(padded.translated(5, 5, s.content_bounds()).tile_count(), s.translated(5, 5, s.content_bounds()).tile_count()); } + // A solid fill moved off the tile grid keeps sharing its one tile inside, at every depth + // and over a non-zero default pixel, and reads exactly like the copied pixels. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let r = Rect::new(-300, -10, 1300, 1030); + s.fill_rect(r, &vec![0.75; fmt.channels()]); + let fill = s.tile(TileCoord::new(1, 1)).cloned().expect("an inner fill tile"); + for (dx, dy) in [(10, 7), (-1, 0), (0, 300), (-257, 513)] { + let t = s.translated(dx, dy, s.content_bounds()); + let moved = r.translate(dx, dy); + assert_eq!(t.read_region(moved.inflate(3)), s.read_region(moved.inflate(3).translate(-dx, -dy)), "{fmt:?} by ({dx}, {dy})"); + // Targets read only from the fill's whole tiles (x -256..1280, y 0..1024). + let whole = Rect::new(-256, 0, 1280, 1024).translate(dx, dy); + let inner: Vec<_> = t.tiles().filter(|(c, _)| whole.contains_rect(&c.rect())).collect(); + assert!(!inner.is_empty() && inner.iter().all(|(_, a)| Arc::ptr_eq(a, &fill)), "{fmt:?} by ({dx}, {dy}): inner tiles copied"); + } + // Nudged back and forth, the inside keeps sharing one tile (copies of uniform edge + // tiles count as that value too) and the pixels stay exact. + let mut t = s.clone(); + for k in 0..8 { + let d = if k % 2 == 0 { 10 } else { -10 }; + t = t.translated(d, d / 2, t.content_bounds()); + } + // (Targets whose last window read uniform tiles only: one tile in from the fill's whole + // tiles on the sides the last move came from.) + let inner: Vec<_> = t.tiles().filter(|(c, _)| Rect::new(-256, 0, 1024, 768).contains_rect(&c.rect())).map(|(_, a)| a).collect(); + assert!(inner.len() >= 4 && inner.iter().all(|a| Arc::ptr_eq(a, inner[0])), "{fmt:?}: repeated moves copied the inside"); + assert_eq!(t.read_region(r.inflate(3)), s.read_region(r.inflate(3)), "{fmt:?}: back where it started"); + } + // Two solid fills of different values side by side: windows across the seam are + // copied (each tile is uniform, but not with the same value), the rest shared. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 1024, 768), &vec![0.2; fmt.channels()]); + s.fill_rect(Rect::new(1024, 0, 2048, 768), &vec![0.9; fmt.channels()]); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 2100, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7)), "{fmt:?}: two fills"); + let seam = t.tile(TileCoord::new(4, 1)).expect("seam tile"); + let (left, right) = (t.tile(TileCoord::new(2, 1)).expect("left"), t.tile(TileCoord::new(6, 1)).expect("right")); + assert!(!Arc::ptr_eq(seam, left) && !Arc::ptr_eq(seam, right) && !Arc::ptr_eq(left, right), "{fmt:?}: seam aliased"); + } + // An offset of i32::MIN saturates its negation, so the source window isn't the exact + // preimage: no shortcut may apply (a zero pre-fill would leak into the output). + let mut s = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + s.fill_rect(Rect::new(i32::MAX - 1024, 0, i32::MAX - 512, 512), &[0.25]); + let (fill, default) = (s.sample_channel(i32::MAX - 600, 10, 0), s.default_pixel()[0]); + for (dx, dy) in [(i32::MIN, 1), (1, i32::MIN), (i32::MIN, 0)] { + let t = s.translated(dx, dy, s.content_bounds()); + for (c, _) in t.tiles() { + assert!(t.read_region(c.rect()).iter().all(|v| *v == fill || *v == default), "({dx}, {dy}): tile {c:?} holds a pre-fill"); + } + } + // One tile shared but not uniform: each target is copied, not aliased. + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + let odd = s.tile(TileCoord::new(0, 0)).cloned().expect("written tile"); + let grid: Vec<_> = (0..9).map(|i| (TileCoord::new(i % 3, i / 3), odd.clone())).collect(); + s.put_tiles(grid); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 800, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7))); + assert!(t.tiles().all(|(_, a)| !Arc::ptr_eq(a, &odd))); // Offsets at the limits don't panic. let mut s = Surface::new(PixelFormat::GRAY8); s.write_pixel(5, 5, &[1.0]); From be43d58f1a1ce798dd2f1c6fdeec90309cc03b11 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:54:57 +0300 Subject: [PATCH 005/223] perf(compose): stop sweeping the bounds cache on every call once it is large (#4415) compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its history), every call (every render tile, every GPU plan) walked the whole map again and kept it all. The sweep now runs past a high-water mark: twice the entries that survived the last sweep, and at least 65,536. The per-tile scan moves to raster as Tile::content_bounds, used by both Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel yields no content instead of a chunks_exact(0) panic. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/compose/src/bounds.rs | 67 +++++++++++++++++++++++------------- crates/raster/src/lib.rs | 49 ++++++++++++++++++-------- 2 files changed, 79 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 1055487f13..985a400e08 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, Weak}; -use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_geom::Rect; use photocraft_raster::{Surface, Tile}; struct Entry { @@ -15,25 +15,23 @@ struct Entry { bounds: Option<(u16, u16, u16, u16)>, } -fn cache() -> &'static Mutex> { - static C: std::sync::OnceLock>> = std::sync::OnceLock::new(); - C.get_or_init(Default::default) +/// The entries, and the size past which dead ones are dropped (see [`next_sweep`]). +struct Cache { + entries: HashMap, + sweep_at: usize, } -/// Pixels of `t` that differ from `dp` (tile-local half-open bounds). -fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { - let bpp = dp.len(); - let ts = TILE_SIZE as usize; - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) +const MIN_SWEEP: usize = 1 << 16; + +/// The size at which to sweep again after a sweep left `survivors` live entries: twice that (at +/// least [`MIN_SWEEP`]), so a cache full of live tiles isn't swept again on every call. +fn next_sweep(survivors: usize) -> usize { + survivors.saturating_mul(2).max(MIN_SWEEP) +} + +fn cache() -> &'static Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } /// `Surface::content_bounds`, cached per tile. @@ -45,18 +43,19 @@ pub fn content_bounds(s: &Surface) -> Rect { let mut dp = vec![0u8; fmt.bytes_per_pixel()]; photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - if c.len() > 1 << 16 { - c.retain(|_, e| e.tile.strong_count() > 0); + if c.entries.len() > c.sweep_at { + c.entries.retain(|_, e| e.tile.strong_count() > 0); + c.sweep_at = next_sweep(c.entries.len()); } let mut out = Rect::EMPTY; for (coord, t) in s.tiles() { let key = Arc::as_ptr(t) as usize; - let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); + let hit = c.entries.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); let b = match hit { Some(b) => b, None => { - let b = scan(t, &dp); - c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + let b = t.content_bounds(&dp); + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); b } }; @@ -68,3 +67,25 @@ pub fn content_bounds(s: &Surface) -> Rect { } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sweeps_again_only_past_twice_the_survivors() { + // A cache of live tiles past the minimum isn't swept on every call (it used to be). + assert_eq!(next_sweep(0), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP / 2), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP), 2 * MIN_SWEEP); + assert_eq!(next_sweep(usize::MAX), usize::MAX); + } + + #[test] + fn a_shared_tile_reads_the_same_bounds_at_every_coordinate() { + let mut s = Surface::new(photocraft_color::PixelFormat::RGBA8); + s.fill_rect(Rect::new(-300, 10, 1300, 900), &[0.2, 0.4, 0.6, 1.0]); + assert_eq!(content_bounds(&s), Rect::new(-300, 10, 1300, 900)); + assert_eq!(content_bounds(&s), s.content_bounds(), "cached"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 74a5870123..2d2d26b2d2 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -81,6 +81,27 @@ impl Tile { pub fn bytes_mut(&mut self) -> &mut [u8] { &mut self.data } + + /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded + /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` + /// is empty). + pub fn content_bounds(&self, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { + let bpp = dp.len(); + if bpp == 0 { + return None; + } + let ts = TILE_SIZE as usize; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { + let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; + let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); + x0 = x0.min(first); + x1 = x1.max(last + 1); + y0 = y0.min(y); + y1 = y + 1; + } + (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) + } } #[derive(Clone, Debug)] @@ -265,7 +286,6 @@ impl Surface { /// Exact bounds of pixels that differ from the default pixel. pub fn content_bounds(&self) -> Rect { - let bpp = self.format.bytes_per_pixel(); if self.tiles.is_empty() { return Rect::EMPTY; } @@ -278,25 +298,14 @@ impl Surface { } let mut order: Vec<(&TileCoord, &Arc)> = self.tiles.iter().collect(); order.sort_by_key(|(c, _)| (c.tx - gx0).min(gx1 - c.tx).min(c.ty - gy0).min(gy1 - c.ty)); - let dp = &*self.default_pixel; - let ts = TILE_SIZE as usize; let mut out = Rect::EMPTY; for (c, t) in order { let origin = c.rect(); if !out.is_empty() && out.contains_rect(&origin) { continue; } - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.data.chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - if x0 != usize::MAX { - out = out.union(&Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).translate(origin.x0, origin.y0)); + if let Some((x0, y0, x1, y1)) = t.content_bounds(&self.default_pixel) { + out = out.union(&Rect::new(x0.into(), y0.into(), x1.into(), y1.into()).translate(origin.x0, origin.y0)); } } out @@ -1246,6 +1255,18 @@ mod tests { } } + #[test] + fn tile_content_bounds_scans_against_the_given_pixel() { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + s.write_pixel(200, 9, &[1.0]); + let t = s.tile(TileCoord::new(0, 0)).expect("written tile"); + assert_eq!(t.content_bounds(&[0]), Some((3, 4, 201, 10))); + assert_eq!(t.content_bounds(&[255]).map(|b| (b.0, b.1)), Some((0, 0))); + // A malformed pixel is no content, not a panic. + assert_eq!(t.content_bounds(&[]), None); + } + #[test] fn translated_matches_reading_the_moved_pixels() { // Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default. From 3c469bfd05b57cf6953e8131a30b95cfb1aa099f Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:05 +0300 Subject: [PATCH 006/223] perf(engine): move layers without a second document clone (#4422) move_layers cloned the document on every layer.translate, Align and Distribute only to read the pre-move state while type and shapes re-render. Session::edit already clones the document for the edit, and the callers hold the starting state (an Arc), so they pass it in. The clone copies the layer tree and every surface's tile map (pixel tiles themselves are shared), so its cost grows with the layer and tile count: a nudge in a document with many layers or very large layers goes from two such clones per command to one. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/engine/src/layer_multi_cmds.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 0d5985e339..b26e46358b 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -261,9 +261,9 @@ pub(crate) fn with_links(doc: &Document, ids: &[LayerId]) -> Vec { } /// Move layers by whole pixels the way the Move tool does: pixels, linked masks, type and -/// vector content (shapes, linked vector masks) all move together. -pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { - let snapshot = doc.clone(); +/// vector content (shapes, linked vector masks) all move together. `before` is `doc` as it was +/// before any of the moves (the edit's starting document), read when type and shapes re-render. +pub(crate) fn move_layers(doc: &mut Document, before: &Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { for &(id, dx, dy) in moves { if dx == 0 && dy == 0 { continue; @@ -273,8 +273,8 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name))); } - crate::commands::translate_layer(&snapshot, l, dx, dy); - crate::vector_cmds::translate_vectors(&snapshot, l, dx as f64, dy as f64); + crate::commands::translate_layer(before, l, dx, dy); + crate::vector_cmds::translate_vectors(before, l, dx as f64, dy as f64); } Ok(()) } @@ -309,7 +309,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { return Err(EngineError::NoLayer(roots[0])); } let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect(); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; for id in alone { let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -873,6 +873,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { let edge = Edge::parse(kind).ok_or_else(|| bad(&cmd, "unknown edge"))?; let sel = selected(s); let to = p.get("to").and_then(Value::as_str).unwrap_or("auto").to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Align", |doc, _| { let items = bounded_selection(doc, &sel); if items.is_empty() { @@ -904,7 +905,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { }) .collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) @@ -927,6 +928,7 @@ fn dedup_moves(doc: &Document, moves: Vec<(LayerId, i32, i32)>) -> Vec<(LayerId, fn distribute(s: &mut Session, kind: &str) -> Result { let sel = selected(s); let kind = kind.to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Distribute", |doc, _| { let mut items = bounded_selection(doc, &sel); if items.len() < 3 { @@ -966,7 +968,7 @@ fn distribute(s: &mut Session, kind: &str) -> Result { } let moves: Vec<_> = moves.into_iter().flat_map(|(id, dx, dy)| with_links(doc, &[id]).into_iter().map(move |l| (l, dx, dy))).collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) From e6831086137b6662596ff7580ae2fd035289f470 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:13 +0300 Subject: [PATCH 007/223] perf(compose): read PHOTOCRAFT_FX_NOCACHE once (#4423) effect_maps looked the environment variable up (taking the process environment lock) for every effect-map lookup, i.e. per styled layer per render tile. It is read once, like PHOTOCRAFT_FX_TRACE in the GPU compositor. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/compose/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cb88d002d9..eafdb44616 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1527,7 +1527,8 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc { use std::hash::{Hash, Hasher}; let (region, clipped) = effect_region(layer, rect, cx); - if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() { + static NO_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) { let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns)); } From 1e9f1ae4862280f6f90b696a8fdb6287cee918e4 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:21 +0300 Subject: [PATCH 008/223] perf(engine): read selection coverage per row when mixing edits back (#4424) Destructive adjustments (pixels::adjusted) and the RGBA filter helper (adjust_cmds process_surface) each had the same loop mixing the edited pixels back over the original by selection coverage, looking the selection's tile up for every pixel (sample_channel, a BTreeMap lookup per pixel). They now share pixels::mix_through_selection, which reads the coverage one row at a time with sample_row_strided: one tile lookup per tile per row, and a row-sized buffer. Results are unchanged: a new test compares the helper with the per-pixel sample_channel mix, bit for bit, for 8-bit, 8-bit-with-alpha and 32-bit selections over 8- and 16-bit layers, across tile edges and untouched (default) selection tiles. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/engine/src/adjust_cmds.rs | 13 ++--------- crates/engine/src/pixels.rs | 35 ++++++++++++++++++++++------- crates/engine/src/pixels_tests.rs | 37 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 48f6e56246..15c3af2060 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect}; -use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -202,17 +202,8 @@ fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn let raw = surf.read_region(r); let mut px: Vec<[f32; 4]> = raw.chunks_exact(n).map(|q| to_rgba(&fmt, q)).collect(); let orig = px.clone(); - let w = r.width() as usize; f(&mut px, r); - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in px.iter().zip(&orig).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); - } - surf.write_region(r, &out); + surf.write_region(r, &crate::pixels::mix_through_selection(&fmt, r, &orig, &px, selection)); } /// One history step running `f` on the active pixel layer (or targeted channel or layer mask). diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index b19a1b270f..f176315ca5 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -99,14 +99,33 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, // integer depths keep the unrounded, clipped curves. let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); - let w = r.width() as usize; - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); + mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) +} + +/// `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( + fmt: &photocraft_color::PixelFormat, + r: Rect, + orig: &[[f32; 4]], + edited: &[[f32; 4]], + selection: Option<&Surface>, +) -> Vec { + let w = (r.width() as usize).max(1); + let mut out = Vec::with_capacity(orig.len() * fmt.channels()); + // The selection's coverage (channel 0) one row at a time, each of its tiles looked up once + // per row rather than once per pixel. + let mut coverage = vec![1.0f32; w]; + for (j, (edited_row, orig_row)) in edited.chunks(w).zip(orig.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 ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) { + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc); + out.extend_from_slice(&enc[..m]); + } } out } diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index c0dc368e40..4a9e577a32 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -259,3 +259,40 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() { assert!(got.iter().zip([70.0, -13.0, 17.0]).all(|(g, w)| (g - w).abs() < 1.0), "{depth:?}: {got:?}"); } } + +#[test] +fn mixing_through_a_selection_matches_sampling_it_per_pixel() { + // A rectangle across tile edges (negative x included), a selection with fractional + // coverage, untouched tiles (default coverage) and a 16-bit as well as an 8-bit layer. + let r = Rect::new(-300, 200, 330, 290); + let gray32 = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }; + for sel_fmt in [PixelFormat::GRAY8, PixelFormat::GRAYA8, gray32] { + let mut sel = Surface::new(sel_fmt); + sel.fill_rect(Rect::new(-260, 210, 40, 280), &vec![0.6; sel_fmt.channels()]); + for x in 0..120 { + sel.write_pixel(x * 2, 230 + x % 40, &vec![x as f32 / 119.0; sel_fmt.channels()]); + } + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let n = (r.width() * r.height()) as usize; + let orig: Vec<[f32; 4]> = (0..n).map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.25 + (i % 3) as f32 / 4.0]).collect(); + let edited: Vec<[f32; 4]> = orig.iter().map(|p| [1.0 - p[0], p[1] * 0.5, p[2], 1.0]).collect(); + let w = r.width() as usize; + let mut expected = Vec::new(); + for (i, (a, o)) in edited.iter().zip(&orig).enumerate() { + let k = sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0); + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); + expected.extend_from_slice(&enc[..m]); + } + assert_eq!(mix_through_selection(&fmt, r, &orig, &edited, Some(&sel)), expected, "{sel_fmt:?} over {fmt:?}"); + } + } + // Without a selection the edit is taken whole; an empty rectangle mixes nothing. + let px = [[0.1, 0.2, 0.3, 1.0]]; + let fmt = PixelFormat::RGBA8; + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, px[0], &mut enc); + assert_eq!(mix_through_selection(&fmt, Rect::new(0, 0, 1, 1), &[[0.0; 4]], &px, None), enc[..m].to_vec()); + assert!(mix_through_selection(&fmt, Rect::EMPTY, &[], &[], None).is_empty()); +} From b26bab69eec557082d859b7813af3a9bda6081c1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:56:47 +0200 Subject: [PATCH 009/223] Layer Style: OK keeps an Inner Glow's Center source (#4152) (#4120) The dialog's values_of() started each Inner Glow from the factory defaults ("source": "edge") and never loaded the effect's own source, so a Center-source glow (from a command or a PSD) showed Edge, previewed as Edge and became Edge on OK, since the engine's overlay applies the "source" key it receives. values_of() now loads the source for Inner Glow. Regression test: inner_glow_center_source_survives_ok. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/layer_style.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index b350cef521..9a64a1f731 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -14,7 +14,7 @@ //! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::effects::{FxPaint, StrokePosition}; +use photocraft_doc::effects::{FxPaint, GlowSource, StrokePosition}; use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; @@ -247,6 +247,9 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "contour", photocraft_engine::layer_style::contour_param(&g.contour)); set(&mut v, "antiAlias", json!(g.anti_alias)); set(&mut v, "noise", json!((g.noise * 100.0).round())); + if matches!(e, Effect::InnerGlow(_)) { + set(&mut v, "source", json!(if g.source == GlowSource::Center { "center" } else { "edge" })); + } } Effect::Stroke(s) => { set(&mut v, "blend", json!(s.common.blend.label())); @@ -1730,6 +1733,30 @@ mod tests { assert_eq!(fx[1]["params"]["spread"], json!(12.0)); } + /// OK with no edits used to turn a Center-source Inner Glow into an Edge one: the dialog never + /// loaded the source, so it showed the default and applied it. + #[test] + fn inner_glow_center_source_survives_ok() { + let mut s = session(); + s.execute("layer.layerStyle.innerGlow", json!({"source": "center"})).unwrap(); + let source = |s: &photocraft_engine::Session| { + let d = s.active().unwrap(); + let id = d.active_layer.unwrap(); + d.doc.layer(id).unwrap().effects.items.iter().find_map(|e| match e { + Effect::InnerGlow(g) => Some(g.source), + _ => None, + }) + }; + assert_eq!(source(&s), Some(GlowSource::Center)); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let glow = effects_of(&f).iter().find(|e| e["kind"] == json!("innerGlow")).unwrap(); + assert_eq!(glow["params"]["source"], json!("center"), "the dialog shows the effect's source"); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + assert_eq!(source(&s), Some(GlowSource::Center), "OK keeps the source"); + } + #[test] fn drop_shadow_angle_reaches_the_effect() { // #350: the Angle slider was dead — the dialog never sent `useGlobalLight`, so the engine From d323d872b2b2a4d109177fd3f711e060288bfd6f Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:56:50 +0200 Subject: [PATCH 010/223] Layers panel: double-clicking an effect row opens that layer's Layer Style (#4157) (#4131) Root cause: the effect rows' double-click handler called layer_style::open, which always reads the active layer. Double-clicking an effect row of a layer that wasn't active opened (and on OK edited) the active layer's style instead. Fix: new layer_style::open_for_layer selects the row's layer with layer.select first, as the row's right-click menu does, then opens the dialog. Regression test covers two layers with only the inactive one carrying an effect. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/layer_style.rs | 34 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 9a64a1f731..d3606e30b7 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -515,6 +515,16 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f)) } +/// Double-click on an effect row of the Layers panel: open Layer Style for that row's layer. A +/// row of another layer selects it first, as its right-click menu does (Photoshop does the same). +pub fn open_for_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId, select: Option<&str>) -> Option { + let active = app.session.active().is_some_and(|s| s.active_layer == Some(layer)); + if !active { + app.run("layer.select", json!({"layer": layer.0})).ok()?; + } + open(app, select) +} + /// `[[id, name], …]` of the patterns a style can use (document's, then the library's). pub fn pattern_list(app: &PhotocraftApp) -> Value { let mut out: Vec = Vec::new(); @@ -1383,6 +1393,30 @@ mod tests { assert_eq!(rows, KINDS.len(), "one empty checkbox per effect row"); } + /// Double-clicking an effect row of a layer that isn't active selects that layer first, so + /// the dialog holds its effects (and OK edits it), not the active layer's. + #[test] + fn effect_row_of_another_layer_opens_that_layers_style() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let with_fx = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerStyle.replace", json!({"layer": with_fx.0, "effects": [{"kind": "dropShadow", "params": defaults("dropShadow")}]})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let plain = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(plain, with_fx); + assert!(app.session.active().unwrap().doc.layer(plain).unwrap().effects.items.is_empty()); + + let id = open_for_layer(&mut app, with_fx, Some("dropShadow")).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(with_fx), "the row's layer becomes active"); + let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap(); + assert_eq!(d.fields["layer"], json!(with_fx.0)); + let fx = effects_of(&d.fields); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0]["kind"], json!("dropShadow")); + assert!(fx[0]["fx"].is_object(), "the existing drop shadow, not a new instance"); + } + #[test] fn every_kind_has_spec_and_defaults() { for &(kind, _) in KINDS { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6193717537..bb4d2f1e90 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3932,7 +3932,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if resp.double_clicked() { - crate::layer_style::open(app, kind); + crate::layer_style::open_for_layer(app, l.id, kind); } // #2846: right-click opens the layer style menu (Create Layer, Copy Layer Style…), as in Photoshop. resp.context_menu(|ui| { From 9fd20de408d0cca4baf96ac4761da3404b44604c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:57:34 +1100 Subject: [PATCH 011/223] Keep Liquify undo and redo working while a numeric field is focused. (#4134) Ctrl+Z was ignored whenever a slider's text edit had focus, so a stroke could not be undone from the keyboard. Other Liquify keys still stay out of that field. The bracket keys also step the brush by at least one pixel, so a 2 px brush can reach 1 px. Fixes #4027 --- crates/ui-egui/src/liquify_ui.rs | 13 ++++++++++--- crates/ui-egui/src/shortcuts.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 3e3a33e8aa..2f429e6bcd 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -605,7 +605,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } } -pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { +/// Ctrl+Z / Ctrl+Shift+Z inside Liquify, including while a slider's text field is focused. +pub fn history_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(d) = app.distort.liquify.as_mut() else { return }; let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { @@ -613,6 +614,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { d.undo(); } +} + +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + history_keys(app, ctx); + let Some(d) = app.distort.liquify.as_mut() else { return }; // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -625,10 +631,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { d.global(LiquifyTool::ThawAll, None); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { - d.opts.size = (d.opts.size * 0.9).max(1.0); + // At least one pixel, or 2 px * 0.9 rounds back to 2 and `[` never reaches 1. + d.opts.size = (d.opts.size * 0.9).round().min(d.opts.size - 1.0).clamp(1.0, 15000.0); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { - d.opts.size = (d.opts.size * 1.1).min(15000.0); + d.opts.size = (d.opts.size * 1.1).round().max(d.opts.size + 1.0).min(15000.0); } let tools = [ (egui::Key::W, LiquifyTool::ForwardWarp), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a156a6ce3b..5b549d6679 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -335,8 +335,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim // keyboard input before the distortion-mode handler below runs. Give Liquify's local // shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits. - if app.distort.liquify.is_some() && !ctx.text_edit_focused() { - crate::liquify_ui::keys(app, ctx); + if app.distort.liquify.is_some() { + // Undo and redo still apply while a numeric field (Size, Pressure, …) has focus. + // Other Liquify keys must not steal characters from that field (#4027). + if ctx.text_edit_focused() { + crate::liquify_ui::history_keys(app, ctx); + } else { + crate::liquify_ui::keys(app, ctx); + } } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); From 4709af046b9ae0b29736dc30a51ccb4362acff2b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:57:49 +1100 Subject: [PATCH 012/223] Draw the Free Transform rotate arrow instead of hiding the pointer. (#4139) The rotate zone requested CursorIcon::None, which removes the system cursor. Crop already draws a turn arrow for that case. Free Transform now uses the same arrow. Fixes #4050 --- crates/ui-egui/src/canvas.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a77d34d48..96643b2024 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3435,10 +3435,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }); if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); - } else if let Some(c) = - response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))) + } 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)) { ui.ctx().set_cursor_icon(c); + // CursorIcon::None hides the pointer; the curved arrow has to be drawn or the + // rotate zone looks like a plain cursor (#4050). + let d = xf.to_doc(p); + if let Some(k) = crate::transform_tool::rotate_cursor_dir(app, d) { + let toward = xf.to_screen((d[0] + k[0] * 16.0) as f32, (d[1] + k[1] * 16.0) as f32) - p; + crate::crop_ui::draw_turn_cursor(&ctx, p, toward); + } } 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(c) = response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p))) { From bfa63f77b476a097ae502794eb261dd204a0efaf Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:58:03 +1100 Subject: [PATCH 013/223] Snap 1 px pencil clicks to the pixel the aliased tip paints. (#4140) A shift-line between two clicks inside the same pixels changed with the sub-pixel position. Pencil and pencil-mode Eraser clicks now use the same grid centre as the dab. Fixes #4071 --- crates/ui-egui/src/canvas.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96643b2024..ebbfc7a40a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4843,10 +4843,16 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; + // A 1 px pencil line depends on the pixels, not where inside the pixel the click + // landed (#4071). Snap both ends of the stroke to the aliased tip's grid. + let (x, y) = snap_aliased_tip(app, tool, x, y); // ⇧-click connects to the last endpoint, including its pen pressure (#2827), so // the renderer interpolates size and opacity across the segment. let active = app.session.active().map(|st| st.doc.id); - let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p); + let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| { + let (sx, sy) = snap_aliased_tip(app, tool, p[0], p[1]); + [sx, sy, p[2]] + }); let mut points = vec![[x, y, pressure as f64]]; if let Some(p) = from { points.insert(0, p); @@ -5132,8 +5138,22 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } +/// Pencil (and a Pencil-mode Eraser) dabs sit on the pixel grid. Clicks anywhere inside one +/// pixel must produce the same point, or a ⇧-click line changes with the sub-pixel position. +fn snap_aliased_tip(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> (f64, f64) { + let pencil = tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool); + if !pencil { + return (x, y); + } + let (cx, cy) = photocraft_engine::paint::grid_center(x, y, app.session.tools.brush.size); + (f64::from(cx), f64::from(cy)) +} + pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { - let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); + end[0] = sx; + end[1] = sy; if let Some(floating) = d.sel_move { finish_selection_drag(app, floating, d.start, [end[0], end[1]]); return; From 5ce608e5f510050592c0c6f7846188c96f00d71e Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:16 +0200 Subject: [PATCH 014/223] Image Size and Canvas Size: no silent resize of large documents in physical units (#4162) (#4163) The Width/Height fields capped non-pixel units at 30000. egui's DragValue clamps the shown value to its range when drawn, and number_edit reports that as an edit, so a 40000 px document at 72 ppi shown in points opened as 30000 pt and OK resized (Image Size) or cropped (Canvas Size) it with no edit. The bound is now the engine's 300000 px limit converted into the current unit, never below 30000. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/sizing.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index f4a9f54f06..6cce7e0a5c 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -166,7 +166,11 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let orig = num(f, orig_key); let px = num(f, key); let mut v = to_unit(px, &unit, orig, res) as f32; - let range = if unit == "px" { -300000.0..=300000.0 } else { -30000.0..=30000.0 }; + // The engine's 300000 px limit in this unit (never below 30000), so a large document's size + // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the + // field reports that as an edit (#4162). + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; @@ -596,6 +600,32 @@ mod tests { assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); } + /// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt) + /// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with + /// no edit resized (Image Size) or cropped (Canvas Size) it. + #[test] + fn large_documents_in_physical_units_open_unchanged() { + use egui_kittest::Harness; + let mut wrong = Vec::new(); + for command in ["image.imageSize", "image.canvasSize"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 40000, "height": 4, "resolution": 72})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), command).unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + let f = fields(&h); + let shown = (num(&f, "width"), num(&f, "height")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let size = h.state().session.active().unwrap().doc.size; + if shown != (40000.0, 4.0) || size != photocraft_doc::Size::new(40000, 4) { + wrong.push(format!("{command}: fields {shown:?}, document {size:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); + } + /// #2329: Image Size and Canvas Size open on Width with its value selected, as in Photoshop: /// typing replaces it and Enter applies it. #[test] From f60202879d7386c2c61c3185dda58de525eba3fd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:30 +0200 Subject: [PATCH 015/223] Exposure: the slider keeps fractional stops (#4164) (#4165) The shared adjustment slider rows rounded to 0.01 for ranges of 10 or less and to whole numbers otherwise, so Exposure (-20..20) snapped to whole stops: 0.5 became 1. Each row can now carry its own decimals: Exposure keeps 2 and Offset 4, as in Photoshop, and their fields show that many places. All other rows (Gamma, Posterize, Threshold, Brightness/Contrast, Vibrance, Hue/Saturation ranges, Photo Filter density) keep the old rule. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/adjust_editors.rs | 46 ++++++++---- crates/ui-egui/src/adjust_editors_tests.rs | 86 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 18 ++++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 84fab70b4b..cc47bb33d1 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -115,18 +115,18 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed ui, v, &[ - ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, ""), - ("offset", tl!("Offset"), -0.5, 0.5, 0.0, ""), - ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, ""), + ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, "", Some(2)), + ("offset", tl!("Offset"), -0.5, 0.5, 0.0, "", Some(4)), + ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, "", None), ], ), - "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%"), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%")]), + "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%", None), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%", None)]), "hueSaturation" => hue_saturation(ui, v, cx), "colorBalance" => color_balance(ui, v, cx), "blackWhite" => black_white(ui, v, cx), "photoFilter" => photo_filter(ui, v, cx), "channelMixer" => channel_mixer(ui, v, cx), - "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "")]), + "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "", None)]), "threshold" => threshold(ui, v, cx), "gradientMap" => gradient_map(ui, v, cx), _ => Edit::default(), @@ -190,15 +190,31 @@ fn label(ui: &mut egui::Ui, text: &str) { } /// Labelled slider rows over top-level keys: (key, label, min, max, default, unit). -fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[(&str, &str, f32, f32, f32, &str)]) -> Edit { +/// A row of [`sliders`]: params key, label, range, default, unit, and its decimal places (None: +/// two for a range of 10 or less, else whole numbers). +type SliderRow<'a> = (&'a str, &'a str, f32, f32, f32, &'a str, Option); + +/// The value a [`sliders`] row stores for `x`: rounded to `decimals` places, or by its range. +pub(crate) fn row_value(x: f32, min: f32, max: f32, decimals: Option) -> f32 { + match decimals { + Some(d) => { + let scale = 10f32.powi(d.min(6) as i32); + (x * scale).round() / scale + } + None if max - min <= 10.0 => round(x, 0.01), + None => x.round(), + } +} + +fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { let mut e = Edit::default(); - for &(key, text, min, max, default, unit) in rows { + for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; - let r = widgets::slider_row_with(ui, text, &mut x, min..=max, unit, None, gestures); + let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { - v[key] = json!(if max - min <= 10.0 { round(x, 0.01) } else { x.round() }); + v[key] = json!(row_value(x, min, max, decimals)); } e.add(Edit::of(&r)); } @@ -222,7 +238,7 @@ fn track_stops<'a>(cx: &EditorCx, stops: &'a [Color32]) -> Option<&'a [Color32]> fn brightness_contrast(ui: &mut egui::Ui, v: &mut Value) -> Edit { let legacy = flag(v, "legacy", false); let lo = if legacy { -100.0 } else { -50.0 }; - let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, ""), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "")]); + let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, "", None), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "", None)]); let mut l = legacy; if widgets::checkbox(ui, &mut l, tl!("Use Legacy")).changed() { v["legacy"] = json!(l); @@ -798,7 +814,7 @@ fn threshold(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { p.line_segment([pos2(x, hr.top()), pos2(x, hr.bottom())], Stroke::new(1.0, t.accent)); p.rect_stroke(hr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); ui.add_space(4.0); - sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "")]) + sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "", None)]) } // --------------------------------------------------------------------------------------------- @@ -912,7 +928,11 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { o["hue"] = json!(h.round()); } sub.add(Edit::of(&r)); - sub.add(sliders(ui, &mut o, &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%"), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%")])); + sub.add(sliders( + ui, + &mut o, + &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%", None), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%", None)], + )); if sub.changed { v[key] = o; } @@ -1143,7 +1163,7 @@ fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { e.add(r); }); ui.data_mut(|d| d.insert_temp(mode_id, use_color)); - e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%")])); + e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%", None)])); let mut pl = flag(v, "preserveLuminosity", true); if widgets::checkbox(ui, &mut pl, tl!("Preserve Luminosity")).changed() { v["preserveLuminosity"] = json!(pl); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 478b2de68d..805bbbdd90 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -664,3 +664,89 @@ fn properties_white_point_eyedropper_edits_the_layer(kind: &str) { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(layer(&h).1, before); } + +/// A bare `kind` editor over `params`, as the Properties panel and the dialog host it. +fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'static, serde_json::Value> { + use crate::adjust_editors::{EditorCx, editor}; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( + move |ui, values: &mut serde_json::Value| { + let cx = EditorCx { + mem: egui::Id::new("precision"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: true, + dynamic_color_sliders: true, + }; + editor(ui, kind, values, &cx); + }, + params, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the `index`th number field and commits it with Enter. +fn type_into_field(h: &mut Harness<'static, serde_json::Value>, index: usize, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +/// The text the `index`th number field shows. +fn field_text(h: &Harness<'static, serde_json::Value>, index: usize) -> String { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().value().unwrap_or_default() +} + +/// #4164: Exposure keeps fractional stops (Photoshop: two decimals) and Offset four decimals, +/// instead of rounding Exposure to whole stops because its range is wider than 10. +#[test] +fn exposure_keeps_fractional_stops_and_offset_four_decimals() { + let mut h = editor_harness("exposure", json!({})); + type_into_field(&mut h, 0, "0.5"); + assert_eq!(h.state()["exposure"].as_f64(), Some(0.5), "{}", h.state()); + type_into_field(&mut h, 0, "-1.37"); + assert!((h.state()["exposure"].as_f64().unwrap() + 1.37).abs() < 1e-4, "{}", h.state()); + type_into_field(&mut h, 1, "0.0123"); + assert!((h.state()["offset"].as_f64().unwrap() - 0.0123).abs() < 1e-6, "{}", h.state()); + type_into_field(&mut h, 2, "1.234"); + assert!((h.state()["gamma"].as_f64().unwrap() - 1.23).abs() < 1e-4, "Gamma keeps two decimals: {}", h.state()); + h.run_steps(2); + assert_eq!(field_text(&h, 0), "-1.37"); + assert_eq!(field_text(&h, 1), "0.0123"); + assert_eq!(field_text(&h, 2), "1.23"); +} + +/// #4164: the integer rows that share Exposure's slider code stay whole numbers. +#[test] +fn integer_adjustment_rows_stay_whole_numbers() { + for (kind, key, typed, want) in [ + ("posterize", "levels", "4.6", 5.0), + ("threshold", "level", "100.4", 100.0), + ("brightnessContrast", "brightness", "12.7", 13.0), + ("vibrance", "vibrance", "-20.4", -20.0), + ] { + let mut h = editor_harness(kind, json!({})); + type_into_field(&mut h, 0, typed); + assert_eq!(h.state()[key].as_f64(), Some(want), "{kind}: {}", h.state()); + } +} + +/// #4164: a row's own decimals win over the range rule; rows without them keep it. +#[test] +fn row_values_round_to_their_own_decimals() { + use crate::adjust_editors::row_value; + assert_eq!(row_value(0.5, -20.0, 20.0, Some(2)), 0.5); + assert_eq!(row_value(-1.374, -20.0, 20.0, Some(2)), -1.37); + assert!((row_value(0.012_34, -0.5, 0.5, Some(4)) - 0.0123).abs() < 1e-7); + assert_eq!(row_value(4.6, 2.0, 255.0, None), 5.0, "wide ranges stay whole numbers"); + assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); + assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a3def4f2d..8d57c51e75 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -973,6 +973,22 @@ pub fn slider_row_with( suffix: &str, gradient: Option<&[Color32]>, g: RowGestures, +) -> Response { + slider_row_prec(ui, label, value, range, suffix, gradient, g, None) +} + +/// [`slider_row_with`] whose field shows `decimals` places instead of the one (or two, for a +/// small range) it picks itself: Exposure's ±20 stops show two, its Offset four (#4164). +#[allow(clippy::too_many_arguments)] +pub fn slider_row_prec( + ui: &mut Ui, + label: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + gradient: Option<&[Color32]>, + g: RowGestures, + decimals: Option, ) -> Response { let t = Tokens::get(ui.ctx()); let mut changed_resp = None; @@ -981,7 +997,7 @@ pub fn slider_row_with( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field(ui, value, range.clone(), suffix, 74.0)); + changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, units: None }).0); }); }); let s = slider_with(ui, value, range, gradient, g); From 4566f5f55fe6728fee6571b22aa18d9eda451c9f Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:43 +0200 Subject: [PATCH 016/223] Shape tools: the click-to-open-dialog tolerance is the same on HiDPI displays (#4205) (#4207) finish_gesture passed the device-pixel zoom (current_zoom) to shape_dialog::is_click, whose 3-point slop is in egui points, so at pixels_per_point 2 the tolerance halved and a slightly jittery click drew a tiny shape instead of opening Create Rectangle/Ellipse/... Pass point_zoom(), as the marquee already does. Regression test: a 2 pt jitter at ppp 2 and 100 % opens the dialog and adds no layer. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/shape_dialog_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ebbfc7a40a..911e428a69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5176,7 +5176,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())), // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) - && crate::shape_dialog::is_click(d.start, &d.points, app.current_zoom()) + && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) && crate::shape_dialog::open(app, t, d.start).is_some() => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]), diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index fde2cf78c7..a0b5c61596 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -141,6 +141,20 @@ fn click_slop_scales_with_zoom() { assert!(is_click([10.0, 10.0], &[], 0.0)); } +/// #4205: the click slop is in screen points, so on a 2x (HiDPI) display at 100 % a 2 pt jitter +/// (4 device px = 4 document px) is still a click that opens Create Rectangle, not a tiny shape. +#[test] +fn a_click_with_jitter_opens_the_dialog_on_hidpi() { + let mut app = app(); + app.ppp = 2.0; + let i = app.session.active_index().unwrap(); + app.ui.views[i].zoom = 1.0; + let before = layers(&app); + press(&mut app, Tool::Rectangle, [20.0, 30.0], [24.0, 30.0]); + only_dialog(&app); + assert_eq!(layers(&app), before, "a jittery click draws nothing"); +} + #[test] fn cancel_draws_nothing_and_ok_remembers_the_size() { let mut app = app(); From f52549571ff0d04d2c660b4c8f012969fc40aabd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:57 +0200 Subject: [PATCH 017/223] Rotate View: the image follows the pointer in a flipped view (#4226) (#4227) ViewXform::to_screen mirrors x after rotating, so with View > Flip Horizontal on a larger rotation turns the image counter-clockwise on screen. The Rotate View drag and the trackpad rotate added their screen delta unchanged, so the image spun against the pointer. A small helper, rotate_view::screen_delta, negates the delta in a flipped view and serves both paths; Shift still snaps the final angle. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/rotate_view.rs | 56 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 911e428a69..af3a11f04d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2900,7 +2900,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { - let next = view.rotation + delta; + let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); } diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 0204ba5b3b..01841b48f7 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -1,6 +1,7 @@ //! Rotate View tool: turn the canvas camera around its centre without rewriting pixels. //! -//! Drag around the view centre (clockwise increases the angle). Shift snaps to 15°. Reset View, +//! Drag around the view centre: the image follows the pointer (clockwise increases the angle, or +//! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with //! Trackpad. @@ -59,6 +60,14 @@ pub fn snap_deg(a: f32, step: f32) -> f32 { wrap_deg((a / step).round() * step) } +/// Turn a pointer or trackpad angle change (screen degrees, clockwise positive) into a change of +/// `View::rotation`. [`ViewXform::to_screen`] mirrors x after rotating, so in a flipped view a +/// larger angle turns the image the other way on screen; negating keeps it under the pointer. +pub fn screen_delta(delta: f32, flip: bool) -> f32 { + let d = wrap_deg(delta); + if flip { wrap_deg(-d) } else { d } +} + /// Pointer angle in degrees around `pivot` (Y-down, so clockwise is positive). pub fn pointer_deg(p: Pos2, pivot: Pos2) -> f32 { let d = p - pivot; @@ -180,7 +189,7 @@ pub fn drag(ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, p return b.started || b.dragged || b.stopped; }; if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { - let delta = wrap_deg(pointer_deg(p, z.pivot) - z.start_pointer); + let delta = screen_delta(pointer_deg(p, z.pivot) - z.start_pointer, xf.flip); let mut angle = z.start_rotation + delta; if !angle.is_finite() { angle = z.start_rotation; @@ -358,6 +367,49 @@ mod tests { assert!((s.x - c.x).abs() < 1e-3 && (s.y - (c.y + 1.0)).abs() < 1e-3, "flip then rotate {s:?}"); } + /// Screen angle, about the canvas centre, of the document point that was under `p0` at the press. + fn followed_angle(h: &Harness<'static, PhotocraftApp>, p0: Pos2, start_rotation: f32) -> f32 { + let st = h.state(); + let (rect, v, flip) = (st.last_canvas_rect, &st.ui.views[0], st.ui.view.flip_horizontal); + let before = ViewXform { rect, zoom: 1.0, center: v.center, flip, rotation: start_rotation, aspect: 1.0 }; + let d = before.to_doc(p0); + let after = ViewXform { rotation: v.rotation, ..before }; + let s = after.to_screen(d[0] as f32, d[1] as f32) - rect.center(); + wrap_deg(s.y.atan2(s.x).to_degrees() - pointer_deg(p0, rect.center())) + } + + #[test] + fn drag_follows_the_pointer_in_a_flipped_view() { + for flip in [false, true] { + let mut h = harness(); + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(2); + let c = h.state().last_canvas_rect.center(); + let p0 = c + vec2(120.0, 0.0); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true, false); + // 30° clockwise about the pivot: the image turns 30° clockwise on screen. + let p1 = c + vec2(120.0 * 30f32.to_radians().cos(), 120.0 * 30f32.to_radians().sin()); + h.event(Event::PointerMoved(p1)); + h.step(); + let got = followed_angle(&h, p0, 0.0); + assert!((got - 30.0).abs() < 0.75, "flip={flip}: the image turned {got}°, the pointer 30°"); + press(&mut h, p1, false, false); + } + } + + #[test] + fn screen_delta_mirrors_only_in_a_flipped_view() { + assert_eq!(screen_delta(12.5, false), 12.5); + assert_eq!(screen_delta(12.5, true), -12.5); + assert_eq!(screen_delta(-180.0, true), 180.0); + assert_eq!(screen_delta(f32::NAN, true), 0.0); + assert_eq!(screen_delta(f32::INFINITY, false), 0.0); + // A flipped view still lands on the snapped angle with Shift. + assert_eq!(snap_deg(0.0 + screen_delta(-40.0, true), SNAP_DEG), 45.0); + } + fn app_docs(n: usize) -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); for i in 0..n { From 9970cf72142796192a6e274bfaa8356e7582f60c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:59:11 +0200 Subject: [PATCH 018/223] Brush resize HUD: horizontal is size and vertical is hardness in a rotated view (#4228) (#4229) The Control+Alt / Alt+right-drag HUD measured the drag in document axes and only corrected for View > Flip Horizontal, so at 90 degrees a horizontal drag changed the hardness and at 180 degrees dragging right shrank the brush. Map the anchor and pointer to screen with the active ViewXform and divide by its zoom: identical numbers at rotation 0 (flipped or not), screen-relative when rotated, as in Photoshop. Without a view the old behaviour remains. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/brush_resize.rs | 47 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index c4239a3586..dfba8e5166 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -111,9 +111,17 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) { } fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { - // Right is bigger on screen, also in a flipped view (View › Flip Horizontal). - let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }; - let (size, v) = resized(r.start, dx, y - r.anchor[1], app.point_zoom()); + // Screen-relative, as in Photoshop (#4228): right is bigger and down harder on screen, however + // the view is rotated or flipped. The screen delta over the zoom is in document pixels at 0°. + let (dx, dy, zoom) = match ViewXform::active(app) { + Some(xf) if xf.zoom.is_finite() && xf.zoom > 0.0 => { + let d = xf.to_screen(x as f32, y as f32) - xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); + (f64::from(d.x / xf.zoom), f64::from(d.y / xf.zoom), xf.zoom) + } + // No view: document axes, mirrored by View › Flip Horizontal. + _ => ((x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }, y - r.anchor[1], app.point_zoom()), + }; + let (size, v) = resized(r.start, dx, dy, zoom); // "Vary Round Brush Hardness on HUD" (Preferences ▸ Tools, on by default): off, the vertical // movement takes the opacity instead of the hardness, as in Photoshop. let vary = app.session.prefs().tools.vary_round_brush_hardness_on_hud; @@ -484,6 +492,39 @@ mod tests { } } + /// One Control+Alt drag by `(dx, dy)` document pixels from (100, 100) in a view turned by + /// `rotation` degrees (flipped with `flip`): the resulting (size, hardness). + fn drag_in_view(rotation: f32, flip: bool, dx: f64, dy: f64) -> (f32, f32) { + let mut app = app(Tool::Brush); + app.sync_views(); + app.ui.views[0].rotation = rotation; + app.ui.view.flip_horizontal = flip; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, CTRL_ALT); + tool_event(&mut app, ToolEvent::Up { x: 100.0 + dx, y: 100.0 + dy }, CTRL_ALT); + let b = &app.session.tools.brush; + (b.size, b.hardness) + } + + /// #4228: the HUD is screen-relative, as in Photoshop: horizontal on screen is the size and + /// vertical the hardness however the view is rotated or flipped. + #[test] + fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { + // Unrotated, with and without View › Flip Horizontal: unchanged. + assert_eq!(drag_in_view(0.0, false, 30.0, 0.0), (100.0, 0.2), "right grows"); + assert_eq!(drag_in_view(0.0, false, 0.0, 40.0), (40.0, 0.4), "down hardens"); + assert_eq!(drag_in_view(0.0, true, -30.0, 0.0), (100.0, 0.2), "flipped: screen right grows"); + assert_eq!(drag_in_view(0.0, true, 0.0, 40.0), (40.0, 0.4), "flipped: down hardens"); + // 90° clockwise: document +y points screen-left and document +x screen-down. + assert_eq!(drag_in_view(90.0, false, 0.0, 10.0), (20.0, 0.2), "screen-left shrinks, the hardness stays"); + assert_eq!(drag_in_view(90.0, false, 0.0, -30.0), (100.0, 0.2), "screen-right grows"); + assert_eq!(drag_in_view(90.0, false, 40.0, 0.0), (40.0, 0.4), "screen-down hardens, the size stays"); + // 180°: document -x is screen right. + assert_eq!(drag_in_view(180.0, false, -30.0, 0.0), (100.0, 0.2), "screen right still grows"); + assert_eq!(drag_in_view(180.0, false, 0.0, -40.0), (40.0, 0.4), "screen down still hardens"); + // 90° and flipped: document +y points screen-right. + assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); + } + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { From 5981a02e64d536b627e301e79805cdd9572b5dbb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:59:36 +0700 Subject: [PATCH 019/223] feat(codecs): support full X11 rgb.txt color names in XPM (#4263) * feat(codecs): import XPM3 images with transparency (#3692) * Correct XPM test expectation for green palette name * feat(codecs): resolve full X11 rgb.txt color names in XPM * XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + apps/photocraft/src/services.rs | 4 +- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/xpm.rs | 192 ++++++ crates/codecs/src/codecs/xpm/x11_colors.rs | 666 +++++++++++++++++++++ crates/codecs/src/format.rs | 13 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/xpm.rs | 93 +++ 8 files changed, 970 insertions(+), 4 deletions(-) create mode 100644 crates/codecs/src/codecs/xpm.rs create mode 100644 crates/codecs/src/codecs/xpm/x11_colors.rs create mode 100644 crates/codecs/tests/xpm.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c2b9f87854..8c838b64d9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ next to them. | Path | Title | Author | Source | License | |---|---|---|---|---| +| `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/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) | diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index faf0b6a11f..b24c2a025a 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", - "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 2a1407d1c4..16a0d0e159 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -14,3 +14,4 @@ pub(crate) mod tiff_ifd; pub(crate) mod via_image; pub(crate) mod vp8; pub(crate) mod webp; +pub(crate) mod xpm; diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs new file mode 100644 index 0000000000..36062f04db --- /dev/null +++ b/crates/codecs/src/codecs/xpm.rs @@ -0,0 +1,192 @@ +//! XPM3 reader for C-source icon arrays. Supports transparency (None), RGB hex +//! at 4/8/16 bits per channel, and common X11 colour names. Does not run C code. + +use std::collections::HashMap; + +mod x11_colors; + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +const MAX_SOURCE: usize = 256 << 20; +const MAX_COLOURS: usize = 65_536; +const MAX_CPP: usize = 8; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Xpm, msg) +} + +/// Extract C string literals without treating quoted text or // in a string as a comment. +/// XPM uses one literal for the header, each palette entry and each row. +fn c_strings(bytes: &[u8]) -> Result>, CodecError> { + let mut lines = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i..].starts_with(b"/*") { + let tail = &bytes[i + 2..]; + let Some(end) = tail.windows(2).position(|p| p == b"*/") else { return Err(bad("unterminated C comment")) }; + i += end + 4; + } else if bytes[i..].starts_with(b"//") { + i += bytes[i..].iter().position(|&c| c == b'\n').unwrap_or(bytes.len() - i); + } else if bytes[i] == b'"' { + i += 1; + let mut s = Vec::new(); + let mut closed = false; + while i < bytes.len() { + let c = bytes[i]; + i += 1; + match c { + b'"' => { + closed = true; + break; + } + b'\\' => { + let next = *bytes.get(i).ok_or_else(|| bad("truncated C escape"))?; + i += 1; + s.push(match next { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'0' => 0, + _ => next, // quoted backslash or quote + }); + } + _ => s.push(c), + } + } + if !closed { + return Err(bad("unterminated XPM string")); + } + lines.push(s); + } else { + i += 1; + } + } + Ok(lines) +} + +fn channel(hex: &str) -> Option { + let v = u32::from_str_radix(hex, 16).ok()?; + let max = (1u32 << (hex.len() * 4)) - 1; + Some(((v * 255 + max / 2) / max) as u8) +} + +fn named(name: &str) -> Option<[u8; 3]> { + let s = name.to_ascii_lowercase().replace([' ', '-', '_'], ""); + // XPM imports X11 names, whose RGB values differ from CSS for names + // such as gray, maroon and purple. Keep these exact rather than guessing. + if let Ok(index) = x11_colors::X11_COLORS.binary_search_by_key(&s.as_str(), |(name, _)| *name) { + return Some(x11_colors::X11_COLORS[index].1); + } + // Retain useful web color aliases not present in the X11 database. + Some(match s.as_str() { + "silver" => [192, 192, 192], + "olive" => [128, 128, 0], + "teal" => [0, 128, 128], + "fuchsia" => [255, 0, 255], + "navyblue" => [0, 0, 128], + _ => return None, + }) +} + +fn color(name: &str) -> Option<[u8; 4]> { + if name.eq_ignore_ascii_case("none") { + return Some([0, 0, 0, 0]); + } + let rgb = if let Some(hex) = name.strip_prefix('#') { + // UTF-8 byte lengths can match a supported hex width while slicing would panic. + // `from_str_radix` would also take a leading `+`: accept hex digits only. + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let digits = match hex.len() { + 3 => 1, + 6 => 2, + 12 => 4, + _ => return None, + }; + let rgb = (0..3).map(|i| channel(&hex[i * digits..(i + 1) * digits])).collect::>>()?; + [rgb[0], rgb[1], rgb[2]] + } else { + named(name)? + }; + Some([rgb[0], rgb[1], rgb[2], 255]) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + if !bytes.starts_with(b"/* XPM */") { + return Err(bad("not an XPM3 image")); + } + if bytes.len() > MAX_SOURCE { + return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into())); + } + let lines = c_strings(bytes)?; + let header = lines.first().ok_or_else(|| bad("missing image header"))?; + let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?; + let mut parts = parts.split_ascii_whitespace(); + let number = |key: &str, s: Option<&str>| -> Result { + s.ok_or_else(|| bad(format!("missing {key}")))?.parse::().map_err(|_| bad(format!("invalid {key}"))) + }; + let width = number("width", parts.next())?; + let height = number("height", parts.next())?; + let colours = number("palette size", parts.next())?; + let cpp = number("chars per pixel", parts.next())?; + if width == 0 || height == 0 || colours == 0 || colours > MAX_COLOURS || !(1..=MAX_CPP).contains(&cpp) { + return Err(bad("invalid XPM dimensions, palette size or chars per pixel")); + } + let (w, h) = (u32::try_from(width).map_err(|_| bad("width overflow"))?, u32::try_from(height).map_err(|_| bad("height overflow"))?); + limits.check(w, h, ChannelLayout::Rgba, SampleType::U8)?; + let required = 1usize.checked_add(colours).and_then(|n| n.checked_add(height)).ok_or_else(|| bad("row count overflow"))?; + if lines.len() < required { + return Err(bad("truncated palette or image rows")); + } + let mut palette = HashMap::, [u8; 4]>::with_capacity(colours); + for line in &lines[1..1 + colours] { + if line.len() < cpp { + return Err(bad("truncated colour key")); + } + let fields = std::str::from_utf8(&line[cpp..]).map_err(|_| bad("invalid colour name"))?; + // XPM priority is colour, gray, four-level gray, then monochrome, + // regardless of the order of keys in the palette entry. + let terms: Vec<&str> = fields.split_ascii_whitespace().collect(); + let mut picked: Option<(u8, String)> = None; + let mut i = 0; + while i < terms.len() { + let priority = match terms[i] { + "c" => 4, + "g" => 3, + "g4" => 2, + "m" => 1, + _ => 0, // Symbolic names ('s') are not colour values. + }; + let start = i + 1; + i = start; + while i < terms.len() && !matches!(terms[i], "c" | "g" | "g4" | "m" | "s") { + i += 1; + } + if priority > 0 && i > start && picked.as_ref().is_none_or(|(current, _)| priority > *current) { + picked = Some((priority, terms[start..i].join(" "))); + } + } + let value = picked.as_ref().and_then(|(_, name)| color(name)).ok_or_else(|| bad("unsupported or missing XPM colour"))?; + if palette.insert(line[..cpp].to_vec(), value).is_some() { + return Err(bad("duplicate XPM palette key")); + } + } + let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?; + let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?]; + for (y, line) in lines[1 + colours..required].iter().enumerate() { + if line.len() != row_len { + return Err(bad("XPM row has the wrong width")); + } + for x in 0..width { + let key = &line[x * cpp..(x + 1) * cpp]; + let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?; + pixels[(y * width + x) * 4..(y * width + x + 1) * 4].copy_from_slice(rgba); + } + } + Image::from_u8(w, h, ChannelLayout::Rgba, pixels) +} diff --git a/crates/codecs/src/codecs/xpm/x11_colors.rs b/crates/codecs/src/codecs/xpm/x11_colors.rs new file mode 100644 index 0000000000..1d47b37061 --- /dev/null +++ b/crates/codecs/src/codecs/xpm/x11_colors.rs @@ -0,0 +1,666 @@ +//! X11 color-name database (X.Org rgb.txt) normalized to lowercase without +//! spaces, underscores, or hyphens. RGB values are *X11* values, not CSS values. +//! Source: https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt +//! License: MIT (X.Org Foundation), see the upstream app/rgb COPYING file. +//! Aliases differing only in case or spaces have been collapsed. +//! Keep sorted: decoder uses binary search without allocation per candidate. + +pub(super) const X11_COLORS: &[(&str, [u8; 3])] = &[ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("antiquewhite1", [255, 239, 219]), + ("antiquewhite2", [238, 223, 204]), + ("antiquewhite3", [205, 192, 176]), + ("antiquewhite4", [139, 131, 120]), + ("aquamarine", [127, 255, 212]), + ("aquamarine1", [127, 255, 212]), + ("aquamarine2", [118, 238, 198]), + ("aquamarine3", [102, 205, 170]), + ("aquamarine4", [69, 139, 116]), + ("azure", [240, 255, 255]), + ("azure1", [240, 255, 255]), + ("azure2", [224, 238, 238]), + ("azure3", [193, 205, 205]), + ("azure4", [131, 139, 139]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("bisque1", [255, 228, 196]), + ("bisque2", [238, 213, 183]), + ("bisque3", [205, 183, 158]), + ("bisque4", [139, 125, 107]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blue1", [0, 0, 255]), + ("blue2", [0, 0, 238]), + ("blue3", [0, 0, 205]), + ("blue4", [0, 0, 139]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("brown1", [255, 64, 64]), + ("brown2", [238, 59, 59]), + ("brown3", [205, 51, 51]), + ("brown4", [139, 35, 35]), + ("burlywood", [222, 184, 135]), + ("burlywood1", [255, 211, 155]), + ("burlywood2", [238, 197, 145]), + ("burlywood3", [205, 170, 125]), + ("burlywood4", [139, 115, 85]), + ("cadetblue", [95, 158, 160]), + ("cadetblue1", [152, 245, 255]), + ("cadetblue2", [142, 229, 238]), + ("cadetblue3", [122, 197, 205]), + ("cadetblue4", [83, 134, 139]), + ("chartreuse", [127, 255, 0]), + ("chartreuse1", [127, 255, 0]), + ("chartreuse2", [118, 238, 0]), + ("chartreuse3", [102, 205, 0]), + ("chartreuse4", [69, 139, 0]), + ("chocolate", [210, 105, 30]), + ("chocolate1", [255, 127, 36]), + ("chocolate2", [238, 118, 33]), + ("chocolate3", [205, 102, 29]), + ("chocolate4", [139, 69, 19]), + ("coral", [255, 127, 80]), + ("coral1", [255, 114, 86]), + ("coral2", [238, 106, 80]), + ("coral3", [205, 91, 69]), + ("coral4", [139, 62, 47]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("cornsilk1", [255, 248, 220]), + ("cornsilk2", [238, 232, 205]), + ("cornsilk3", [205, 200, 177]), + ("cornsilk4", [139, 136, 120]), + ("cyan", [0, 255, 255]), + ("cyan1", [0, 255, 255]), + ("cyan2", [0, 238, 238]), + ("cyan3", [0, 205, 205]), + ("cyan4", [0, 139, 139]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgoldenrod1", [255, 185, 15]), + ("darkgoldenrod2", [238, 173, 14]), + ("darkgoldenrod3", [205, 149, 12]), + ("darkgoldenrod4", [139, 101, 8]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkolivegreen1", [202, 255, 112]), + ("darkolivegreen2", [188, 238, 104]), + ("darkolivegreen3", [162, 205, 90]), + ("darkolivegreen4", [110, 139, 61]), + ("darkorange", [255, 140, 0]), + ("darkorange1", [255, 127, 0]), + ("darkorange2", [238, 118, 0]), + ("darkorange3", [205, 102, 0]), + ("darkorange4", [139, 69, 0]), + ("darkorchid", [153, 50, 204]), + ("darkorchid1", [191, 62, 255]), + ("darkorchid2", [178, 58, 238]), + ("darkorchid3", [154, 50, 205]), + ("darkorchid4", [104, 34, 139]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkseagreen1", [193, 255, 193]), + ("darkseagreen2", [180, 238, 180]), + ("darkseagreen3", [155, 205, 155]), + ("darkseagreen4", [105, 139, 105]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategray1", [151, 255, 255]), + ("darkslategray2", [141, 238, 238]), + ("darkslategray3", [121, 205, 205]), + ("darkslategray4", [82, 139, 139]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deeppink1", [255, 20, 147]), + ("deeppink2", [238, 18, 137]), + ("deeppink3", [205, 16, 118]), + ("deeppink4", [139, 10, 80]), + ("deepskyblue", [0, 191, 255]), + ("deepskyblue1", [0, 191, 255]), + ("deepskyblue2", [0, 178, 238]), + ("deepskyblue3", [0, 154, 205]), + ("deepskyblue4", [0, 104, 139]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("dodgerblue1", [30, 144, 255]), + ("dodgerblue2", [28, 134, 238]), + ("dodgerblue3", [24, 116, 205]), + ("dodgerblue4", [16, 78, 139]), + ("firebrick", [178, 34, 34]), + ("firebrick1", [255, 48, 48]), + ("firebrick2", [238, 44, 44]), + ("firebrick3", [205, 38, 38]), + ("firebrick4", [139, 26, 26]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("gold1", [255, 215, 0]), + ("gold2", [238, 201, 0]), + ("gold3", [205, 173, 0]), + ("gold4", [139, 117, 0]), + ("goldenrod", [218, 165, 32]), + ("goldenrod1", [255, 193, 37]), + ("goldenrod2", [238, 180, 34]), + ("goldenrod3", [205, 155, 29]), + ("goldenrod4", [139, 105, 20]), + ("gray", [190, 190, 190]), + ("gray0", [0, 0, 0]), + ("gray1", [3, 3, 3]), + ("gray10", [26, 26, 26]), + ("gray100", [255, 255, 255]), + ("gray11", [28, 28, 28]), + ("gray12", [31, 31, 31]), + ("gray13", [33, 33, 33]), + ("gray14", [36, 36, 36]), + ("gray15", [38, 38, 38]), + ("gray16", [41, 41, 41]), + ("gray17", [43, 43, 43]), + ("gray18", [46, 46, 46]), + ("gray19", [48, 48, 48]), + ("gray2", [5, 5, 5]), + ("gray20", [51, 51, 51]), + ("gray21", [54, 54, 54]), + ("gray22", [56, 56, 56]), + ("gray23", [59, 59, 59]), + ("gray24", [61, 61, 61]), + ("gray25", [64, 64, 64]), + ("gray26", [66, 66, 66]), + ("gray27", [69, 69, 69]), + ("gray28", [71, 71, 71]), + ("gray29", [74, 74, 74]), + ("gray3", [8, 8, 8]), + ("gray30", [77, 77, 77]), + ("gray31", [79, 79, 79]), + ("gray32", [82, 82, 82]), + ("gray33", [84, 84, 84]), + ("gray34", [87, 87, 87]), + ("gray35", [89, 89, 89]), + ("gray36", [92, 92, 92]), + ("gray37", [94, 94, 94]), + ("gray38", [97, 97, 97]), + ("gray39", [99, 99, 99]), + ("gray4", [10, 10, 10]), + ("gray40", [102, 102, 102]), + ("gray41", [105, 105, 105]), + ("gray42", [107, 107, 107]), + ("gray43", [110, 110, 110]), + ("gray44", [112, 112, 112]), + ("gray45", [115, 115, 115]), + ("gray46", [117, 117, 117]), + ("gray47", [120, 120, 120]), + ("gray48", [122, 122, 122]), + ("gray49", [125, 125, 125]), + ("gray5", [13, 13, 13]), + ("gray50", [127, 127, 127]), + ("gray51", [130, 130, 130]), + ("gray52", [133, 133, 133]), + ("gray53", [135, 135, 135]), + ("gray54", [138, 138, 138]), + ("gray55", [140, 140, 140]), + ("gray56", [143, 143, 143]), + ("gray57", [145, 145, 145]), + ("gray58", [148, 148, 148]), + ("gray59", [150, 150, 150]), + ("gray6", [15, 15, 15]), + ("gray60", [153, 153, 153]), + ("gray61", [156, 156, 156]), + ("gray62", [158, 158, 158]), + ("gray63", [161, 161, 161]), + ("gray64", [163, 163, 163]), + ("gray65", [166, 166, 166]), + ("gray66", [168, 168, 168]), + ("gray67", [171, 171, 171]), + ("gray68", [173, 173, 173]), + ("gray69", [176, 176, 176]), + ("gray7", [18, 18, 18]), + ("gray70", [179, 179, 179]), + ("gray71", [181, 181, 181]), + ("gray72", [184, 184, 184]), + ("gray73", [186, 186, 186]), + ("gray74", [189, 189, 189]), + ("gray75", [191, 191, 191]), + ("gray76", [194, 194, 194]), + ("gray77", [196, 196, 196]), + ("gray78", [199, 199, 199]), + ("gray79", [201, 201, 201]), + ("gray8", [20, 20, 20]), + ("gray80", [204, 204, 204]), + ("gray81", [207, 207, 207]), + ("gray82", [209, 209, 209]), + ("gray83", [212, 212, 212]), + ("gray84", [214, 214, 214]), + ("gray85", [217, 217, 217]), + ("gray86", [219, 219, 219]), + ("gray87", [222, 222, 222]), + ("gray88", [224, 224, 224]), + ("gray89", [227, 227, 227]), + ("gray9", [23, 23, 23]), + ("gray90", [229, 229, 229]), + ("gray91", [232, 232, 232]), + ("gray92", [235, 235, 235]), + ("gray93", [237, 237, 237]), + ("gray94", [240, 240, 240]), + ("gray95", [242, 242, 242]), + ("gray96", [245, 245, 245]), + ("gray97", [247, 247, 247]), + ("gray98", [250, 250, 250]), + ("gray99", [252, 252, 252]), + ("green", [0, 255, 0]), + ("green1", [0, 255, 0]), + ("green2", [0, 238, 0]), + ("green3", [0, 205, 0]), + ("green4", [0, 139, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [190, 190, 190]), + ("grey0", [0, 0, 0]), + ("grey1", [3, 3, 3]), + ("grey10", [26, 26, 26]), + ("grey100", [255, 255, 255]), + ("grey11", [28, 28, 28]), + ("grey12", [31, 31, 31]), + ("grey13", [33, 33, 33]), + ("grey14", [36, 36, 36]), + ("grey15", [38, 38, 38]), + ("grey16", [41, 41, 41]), + ("grey17", [43, 43, 43]), + ("grey18", [46, 46, 46]), + ("grey19", [48, 48, 48]), + ("grey2", [5, 5, 5]), + ("grey20", [51, 51, 51]), + ("grey21", [54, 54, 54]), + ("grey22", [56, 56, 56]), + ("grey23", [59, 59, 59]), + ("grey24", [61, 61, 61]), + ("grey25", [64, 64, 64]), + ("grey26", [66, 66, 66]), + ("grey27", [69, 69, 69]), + ("grey28", [71, 71, 71]), + ("grey29", [74, 74, 74]), + ("grey3", [8, 8, 8]), + ("grey30", [77, 77, 77]), + ("grey31", [79, 79, 79]), + ("grey32", [82, 82, 82]), + ("grey33", [84, 84, 84]), + ("grey34", [87, 87, 87]), + ("grey35", [89, 89, 89]), + ("grey36", [92, 92, 92]), + ("grey37", [94, 94, 94]), + ("grey38", [97, 97, 97]), + ("grey39", [99, 99, 99]), + ("grey4", [10, 10, 10]), + ("grey40", [102, 102, 102]), + ("grey41", [105, 105, 105]), + ("grey42", [107, 107, 107]), + ("grey43", [110, 110, 110]), + ("grey44", [112, 112, 112]), + ("grey45", [115, 115, 115]), + ("grey46", [117, 117, 117]), + ("grey47", [120, 120, 120]), + ("grey48", [122, 122, 122]), + ("grey49", [125, 125, 125]), + ("grey5", [13, 13, 13]), + ("grey50", [127, 127, 127]), + ("grey51", [130, 130, 130]), + ("grey52", [133, 133, 133]), + ("grey53", [135, 135, 135]), + ("grey54", [138, 138, 138]), + ("grey55", [140, 140, 140]), + ("grey56", [143, 143, 143]), + ("grey57", [145, 145, 145]), + ("grey58", [148, 148, 148]), + ("grey59", [150, 150, 150]), + ("grey6", [15, 15, 15]), + ("grey60", [153, 153, 153]), + ("grey61", [156, 156, 156]), + ("grey62", [158, 158, 158]), + ("grey63", [161, 161, 161]), + ("grey64", [163, 163, 163]), + ("grey65", [166, 166, 166]), + ("grey66", [168, 168, 168]), + ("grey67", [171, 171, 171]), + ("grey68", [173, 173, 173]), + ("grey69", [176, 176, 176]), + ("grey7", [18, 18, 18]), + ("grey70", [179, 179, 179]), + ("grey71", [181, 181, 181]), + ("grey72", [184, 184, 184]), + ("grey73", [186, 186, 186]), + ("grey74", [189, 189, 189]), + ("grey75", [191, 191, 191]), + ("grey76", [194, 194, 194]), + ("grey77", [196, 196, 196]), + ("grey78", [199, 199, 199]), + ("grey79", [201, 201, 201]), + ("grey8", [20, 20, 20]), + ("grey80", [204, 204, 204]), + ("grey81", [207, 207, 207]), + ("grey82", [209, 209, 209]), + ("grey83", [212, 212, 212]), + ("grey84", [214, 214, 214]), + ("grey85", [217, 217, 217]), + ("grey86", [219, 219, 219]), + ("grey87", [222, 222, 222]), + ("grey88", [224, 224, 224]), + ("grey89", [227, 227, 227]), + ("grey9", [23, 23, 23]), + ("grey90", [229, 229, 229]), + ("grey91", [232, 232, 232]), + ("grey92", [235, 235, 235]), + ("grey93", [237, 237, 237]), + ("grey94", [240, 240, 240]), + ("grey95", [242, 242, 242]), + ("grey96", [245, 245, 245]), + ("grey97", [247, 247, 247]), + ("grey98", [250, 250, 250]), + ("grey99", [252, 252, 252]), + ("honeydew", [240, 255, 240]), + ("honeydew1", [240, 255, 240]), + ("honeydew2", [224, 238, 224]), + ("honeydew3", [193, 205, 193]), + ("honeydew4", [131, 139, 131]), + ("hotpink", [255, 105, 180]), + ("hotpink1", [255, 110, 180]), + ("hotpink2", [238, 106, 167]), + ("hotpink3", [205, 96, 144]), + ("hotpink4", [139, 58, 98]), + ("indianred", [205, 92, 92]), + ("indianred1", [255, 106, 106]), + ("indianred2", [238, 99, 99]), + ("indianred3", [205, 85, 85]), + ("indianred4", [139, 58, 58]), + ("ivory", [255, 255, 240]), + ("ivory1", [255, 255, 240]), + ("ivory2", [238, 238, 224]), + ("ivory3", [205, 205, 193]), + ("ivory4", [139, 139, 131]), + ("khaki", [240, 230, 140]), + ("khaki1", [255, 246, 143]), + ("khaki2", [238, 230, 133]), + ("khaki3", [205, 198, 115]), + ("khaki4", [139, 134, 78]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lavenderblush1", [255, 240, 245]), + ("lavenderblush2", [238, 224, 229]), + ("lavenderblush3", [205, 193, 197]), + ("lavenderblush4", [139, 131, 134]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lemonchiffon1", [255, 250, 205]), + ("lemonchiffon2", [238, 233, 191]), + ("lemonchiffon3", [205, 201, 165]), + ("lemonchiffon4", [139, 137, 112]), + ("lightblue", [173, 216, 230]), + ("lightblue1", [191, 239, 255]), + ("lightblue2", [178, 223, 238]), + ("lightblue3", [154, 192, 205]), + ("lightblue4", [104, 131, 139]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightcyan1", [224, 255, 255]), + ("lightcyan2", [209, 238, 238]), + ("lightcyan3", [180, 205, 205]), + ("lightcyan4", [122, 139, 139]), + ("lightgoldenrod", [238, 221, 130]), + ("lightgoldenrod1", [255, 236, 139]), + ("lightgoldenrod2", [238, 220, 130]), + ("lightgoldenrod3", [205, 190, 112]), + ("lightgoldenrod4", [139, 129, 76]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightpink1", [255, 174, 185]), + ("lightpink2", [238, 162, 173]), + ("lightpink3", [205, 140, 149]), + ("lightpink4", [139, 95, 101]), + ("lightsalmon", [255, 160, 122]), + ("lightsalmon1", [255, 160, 122]), + ("lightsalmon2", [238, 149, 114]), + ("lightsalmon3", [205, 129, 98]), + ("lightsalmon4", [139, 87, 66]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightskyblue1", [176, 226, 255]), + ("lightskyblue2", [164, 211, 238]), + ("lightskyblue3", [141, 182, 205]), + ("lightskyblue4", [96, 123, 139]), + ("lightslateblue", [132, 112, 255]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightsteelblue1", [202, 225, 255]), + ("lightsteelblue2", [188, 210, 238]), + ("lightsteelblue3", [162, 181, 205]), + ("lightsteelblue4", [110, 123, 139]), + ("lightyellow", [255, 255, 224]), + ("lightyellow1", [255, 255, 224]), + ("lightyellow2", [238, 238, 209]), + ("lightyellow3", [205, 205, 180]), + ("lightyellow4", [139, 139, 122]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("magenta1", [255, 0, 255]), + ("magenta2", [238, 0, 238]), + ("magenta3", [205, 0, 205]), + ("magenta4", [139, 0, 139]), + ("maroon", [176, 48, 96]), + ("maroon1", [255, 52, 179]), + ("maroon2", [238, 48, 167]), + ("maroon3", [205, 41, 144]), + ("maroon4", [139, 28, 98]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumorchid1", [224, 102, 255]), + ("mediumorchid2", [209, 95, 238]), + ("mediumorchid3", [180, 82, 205]), + ("mediumorchid4", [122, 55, 139]), + ("mediumpurple", [147, 112, 219]), + ("mediumpurple1", [171, 130, 255]), + ("mediumpurple2", [159, 121, 238]), + ("mediumpurple3", [137, 104, 205]), + ("mediumpurple4", [93, 71, 139]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("mistyrose1", [255, 228, 225]), + ("mistyrose2", [238, 213, 210]), + ("mistyrose3", [205, 183, 181]), + ("mistyrose4", [139, 125, 123]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navajowhite1", [255, 222, 173]), + ("navajowhite2", [238, 207, 161]), + ("navajowhite3", [205, 179, 139]), + ("navajowhite4", [139, 121, 94]), + ("navy", [0, 0, 128]), + ("navyblue", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olivedrab", [107, 142, 35]), + ("olivedrab1", [192, 255, 62]), + ("olivedrab2", [179, 238, 58]), + ("olivedrab3", [154, 205, 50]), + ("olivedrab4", [105, 139, 34]), + ("orange", [255, 165, 0]), + ("orange1", [255, 165, 0]), + ("orange2", [238, 154, 0]), + ("orange3", [205, 133, 0]), + ("orange4", [139, 90, 0]), + ("orangered", [255, 69, 0]), + ("orangered1", [255, 69, 0]), + ("orangered2", [238, 64, 0]), + ("orangered3", [205, 55, 0]), + ("orangered4", [139, 37, 0]), + ("orchid", [218, 112, 214]), + ("orchid1", [255, 131, 250]), + ("orchid2", [238, 122, 233]), + ("orchid3", [205, 105, 201]), + ("orchid4", [139, 71, 137]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("palegreen1", [154, 255, 154]), + ("palegreen2", [144, 238, 144]), + ("palegreen3", [124, 205, 124]), + ("palegreen4", [84, 139, 84]), + ("paleturquoise", [175, 238, 238]), + ("paleturquoise1", [187, 255, 255]), + ("paleturquoise2", [174, 238, 238]), + ("paleturquoise3", [150, 205, 205]), + ("paleturquoise4", [102, 139, 139]), + ("palevioletred", [219, 112, 147]), + ("palevioletred1", [255, 130, 171]), + ("palevioletred2", [238, 121, 159]), + ("palevioletred3", [205, 104, 137]), + ("palevioletred4", [139, 71, 93]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peachpuff1", [255, 218, 185]), + ("peachpuff2", [238, 203, 173]), + ("peachpuff3", [205, 175, 149]), + ("peachpuff4", [139, 119, 101]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("pink1", [255, 181, 197]), + ("pink2", [238, 169, 184]), + ("pink3", [205, 145, 158]), + ("pink4", [139, 99, 108]), + ("plum", [221, 160, 221]), + ("plum1", [255, 187, 255]), + ("plum2", [238, 174, 238]), + ("plum3", [205, 150, 205]), + ("plum4", [139, 102, 139]), + ("powderblue", [176, 224, 230]), + ("purple", [160, 32, 240]), + ("purple1", [155, 48, 255]), + ("purple2", [145, 44, 238]), + ("purple3", [125, 38, 205]), + ("purple4", [85, 26, 139]), + ("red", [255, 0, 0]), + ("red1", [255, 0, 0]), + ("red2", [238, 0, 0]), + ("red3", [205, 0, 0]), + ("red4", [139, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("rosybrown1", [255, 193, 193]), + ("rosybrown2", [238, 180, 180]), + ("rosybrown3", [205, 155, 155]), + ("rosybrown4", [139, 105, 105]), + ("royalblue", [65, 105, 225]), + ("royalblue1", [72, 118, 255]), + ("royalblue2", [67, 110, 238]), + ("royalblue3", [58, 95, 205]), + ("royalblue4", [39, 64, 139]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("salmon1", [255, 140, 105]), + ("salmon2", [238, 130, 98]), + ("salmon3", [205, 112, 84]), + ("salmon4", [139, 76, 57]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seagreen1", [84, 255, 159]), + ("seagreen2", [78, 238, 148]), + ("seagreen3", [67, 205, 128]), + ("seagreen4", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("seashell1", [255, 245, 238]), + ("seashell2", [238, 229, 222]), + ("seashell3", [205, 197, 191]), + ("seashell4", [139, 134, 130]), + ("sienna", [160, 82, 45]), + ("sienna1", [255, 130, 71]), + ("sienna2", [238, 121, 66]), + ("sienna3", [205, 104, 57]), + ("sienna4", [139, 71, 38]), + ("skyblue", [135, 206, 235]), + ("skyblue1", [135, 206, 255]), + ("skyblue2", [126, 192, 238]), + ("skyblue3", [108, 166, 205]), + ("skyblue4", [74, 112, 139]), + ("slateblue", [106, 90, 205]), + ("slateblue1", [131, 111, 255]), + ("slateblue2", [122, 103, 238]), + ("slateblue3", [105, 89, 205]), + ("slateblue4", [71, 60, 139]), + ("slategray", [112, 128, 144]), + ("slategray1", [198, 226, 255]), + ("slategray2", [185, 211, 238]), + ("slategray3", [159, 182, 205]), + ("slategray4", [108, 123, 139]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("snow1", [255, 250, 250]), + ("snow2", [238, 233, 233]), + ("snow3", [205, 201, 201]), + ("snow4", [139, 137, 137]), + ("springgreen", [0, 255, 127]), + ("springgreen1", [0, 255, 127]), + ("springgreen2", [0, 238, 118]), + ("springgreen3", [0, 205, 102]), + ("springgreen4", [0, 139, 69]), + ("steelblue", [70, 130, 180]), + ("steelblue1", [99, 184, 255]), + ("steelblue2", [92, 172, 238]), + ("steelblue3", [79, 148, 205]), + ("steelblue4", [54, 100, 139]), + ("tan", [210, 180, 140]), + ("tan1", [255, 165, 79]), + ("tan2", [238, 154, 73]), + ("tan3", [205, 133, 63]), + ("tan4", [139, 90, 43]), + ("thistle", [216, 191, 216]), + ("thistle1", [255, 225, 255]), + ("thistle2", [238, 210, 238]), + ("thistle3", [205, 181, 205]), + ("thistle4", [139, 123, 139]), + ("tomato", [255, 99, 71]), + ("tomato1", [255, 99, 71]), + ("tomato2", [238, 92, 66]), + ("tomato3", [205, 79, 57]), + ("tomato4", [139, 54, 38]), + ("turquoise", [64, 224, 208]), + ("turquoise1", [0, 245, 255]), + ("turquoise2", [0, 229, 238]), + ("turquoise3", [0, 197, 205]), + ("turquoise4", [0, 134, 139]), + ("violet", [238, 130, 238]), + ("violetred", [208, 32, 144]), + ("violetred1", [255, 62, 150]), + ("violetred2", [238, 58, 140]), + ("violetred3", [205, 50, 120]), + ("violetred4", [139, 34, 82]), + ("wheat", [245, 222, 179]), + ("wheat1", [255, 231, 186]), + ("wheat2", [238, 216, 174]), + ("wheat3", [205, 186, 150]), + ("wheat4", [139, 126, 102]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellow1", [255, 255, 0]), + ("yellow2", [238, 238, 0]), + ("yellow3", [205, 205, 0]), + ("yellow4", [139, 139, 0]), + ("yellowgreen", [154, 205, 50]), +]; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 4a76c14458..ac5fb39487 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -17,6 +17,8 @@ pub enum Format { Pnm, /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. Pcx, + /// X PixMap icons (XPM3 C-string representation). + Xpm, Qoi, OpenExr, /// Radiance RGBE. @@ -60,6 +62,7 @@ pub struct FormatCaps { /// Formats that are enabled but intentionally not symmetric, with the reason. /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ + (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", @@ -86,7 +89,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 17] = [ + pub const ALL: [Format; 18] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -97,6 +100,7 @@ impl Format { Format::Ico, Format::Pnm, Format::Pcx, + Format::Xpm, Format::Qoi, Format::OpenExr, Format::Hdr, @@ -128,6 +132,7 @@ impl Format { Format::Ico => "ICO", Format::Pnm => "Netpbm", Format::Pcx => "PCX", + Format::Xpm => "XPM", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", @@ -152,6 +157,7 @@ impl Format { Format::Ico => &["ico"], Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], Format::Pcx => &["pcx"], + Format::Xpm => &["xpm"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], @@ -174,6 +180,7 @@ impl Format { Format::Ico => "image/x-icon", Format::Pnm => "image/x-portable-anymap", Format::Pcx => "image/x-pcx", + Format::Xpm => "image/x-xpixmap", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", @@ -224,6 +231,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base }, Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, + Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -288,6 +296,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if b.starts_with(b"/* XPM */") { + return Some(Format::Xpm); + } if crate::codecs::pcx::looks_like_pcx(b) { return Some(Format::Pcx); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index e90b5235c4..3974929ee0 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -75,6 +75,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), + Format::Xpm => xpm::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), @@ -225,6 +226,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), + Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/xpm.rs new file mode 100644 index 0000000000..8727e5dbc3 --- /dev/null +++ b/crates/codecs/tests/xpm.rs @@ -0,0 +1,93 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, Image, caps, decode, decode_as, detect, encode, from_extension}; + +const SAMPLE: &[u8] = br#"/* XPM */ +static char *icon[] = { +"2 2 3 1", +". c #ff0000", +" c None", +"+ c #00ff00", +".+", +" +" +}; +"#; + +#[test] +fn xpm3_decodes_palette_transparency_and_spaces_in_keys() { + assert_eq!(detect(SAMPLE), Some(Format::Xpm)); + assert_eq!(from_extension("icon.XPM"), Some(Format::Xpm)); + let img = decode(SAMPLE).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.dimensions(), (2, 2)); + assert_eq!(img.data(), &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn rgb_precisions_and_x11_name() { + let xpm = br#"/* XPM */ +static char *i[] = { +"3 1 3 1", +"a c #f00", +"b c #ffff00000000", +"c c gray70", +"abc" +};"#; + let img = decode(xpm).unwrap(); + assert_eq!(img.data(), &[255, 0, 0, 255, 255, 0, 0, 255, 179, 179, 179, 255]); +} + +#[test] +fn rejects_truncation_unsupported_colours_and_unknown_keys() { + for bytes in [ + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c None\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c NotAColor\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #a\xC3\xA9\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #+f0000\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"z\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"aa\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 2 1\", \"a c black\", \"a c white\", \"a\" };"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{:?}", bytes); + } + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"4294967295 4294967295 1 1\"").is_err()); + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"2 1 1 1\" \"a c black\" \"aa\" /* bad").is_err()); +} + +#[test] +fn x11_names_and_field_priority() { + let xpm = br#"/* XPM */ +static char *i[] = { +"4 1 4 1", +"a c LightGray", +"b c DarkBlue", +"c m black g white c light blue", +"d m black g4 red g green", +"abcd" +};"#; + let image = decode(xpm).unwrap(); + assert_eq!(image.data(), &[211, 211, 211, 255, 0, 0, 139, 255, 173, 216, 230, 255, 0, 255, 0, 255]); +} + +#[test] +fn named_x11_colors_use_x11_not_css_values() { + let bytes = br#"/* XPM */ +static char *icon[] = { +"6 1 6 1", +"a c gray", +"b c maroon", +"c c purple", +"d c LightGoldenrodYellow", +"e c DarkSlateGray4", +"f c Ghost White", +"abcdef" +};"#; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]); +} + +#[test] +fn xpm_is_import_only() { + assert!(caps(Format::Xpm).read); + assert!(!caps(Format::Xpm).write); + let image = Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&image, Format::Xpm, &Default::default()), Err(CodecError::Unsupported { .. }))); +} From 4a53fad835bb2e1b1afb9daccf6ef9aecb39368b Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:01:35 +0700 Subject: [PATCH 020/223] feat(codecs): add XPM2 plain-text image import support (#4264) * feat(codecs): import XPM3 images with transparency (#3692) * Correct XPM test expectation for green palette name * feat(codecs): decode XPM2 plain-text image files --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/xpm.rs | 41 ++++++++++++++++++++++++++++----- crates/codecs/src/format.rs | 4 ++-- crates/codecs/tests/xpm.rs | 30 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs index 36062f04db..82543d8867 100644 --- a/crates/codecs/src/codecs/xpm.rs +++ b/crates/codecs/src/codecs/xpm.rs @@ -1,5 +1,6 @@ -//! XPM3 reader for C-source icon arrays. Supports transparency (None), RGB hex -//! at 4/8/16 bits per channel, and common X11 colour names. Does not run C code. +//! X PixMap reader for XPM2 plain-text icons and XPM3 C-source icon arrays. +//! Supports transparency (None), RGB hex at 4/8/16 bits per channel and +//! common X11 colour names. Does not execute C code. use std::collections::HashMap; @@ -68,6 +69,27 @@ fn c_strings(bytes: &[u8]) -> Result>, CodecError> { Ok(lines) } +/// Extract XPM2's plain-text lines without trimming pixel rows: leading and +/// trailing spaces can be palette keys, and physical CRLF is not image data. +fn xpm2_lines(bytes: &[u8]) -> Result>, CodecError> { + let mut input = bytes.split(|&b| b == b'\n'); + let header = input.next().ok_or_else(|| bad("missing XPM2 marker"))?; + if header.strip_suffix(b"\r").unwrap_or(header) != b"! XPM2" { + return Err(bad("not an XPM2 image")); + } + let mut lines = Vec::new(); + for raw in input { + let line = raw.strip_suffix(b"\r").unwrap_or(raw); + // The header may be preceded by comments and blank lines. After the + // header, a leading '!' is allowed as a palette key or pixel value. + if lines.is_empty() && (line.is_empty() || line.starts_with(b"!")) { + continue; + } + lines.push(line.to_vec()); + } + Ok(lines) +} + fn channel(hex: &str) -> Option { let v = u32::from_str_radix(hex, 16).ok()?; let max = (1u32 << (hex.len() * 4)) - 1; @@ -117,13 +139,20 @@ fn color(name: &str) -> Option<[u8; 4]> { } pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - if !bytes.starts_with(b"/* XPM */") { - return Err(bad("not an XPM3 image")); - } if bytes.len() > MAX_SOURCE { return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into())); } - let lines = c_strings(bytes)?; + let lines = if bytes.starts_with(b"/* XPM */") { + c_strings(bytes)? + } else if bytes.starts_with(b"! XPM2\n") || bytes.starts_with(b"! XPM2\r\n") { + xpm2_lines(bytes)? + } else { + return Err(bad("not an XPM2/XPM3 image")); + }; + decode_lines(&lines, limits) +} + +fn decode_lines(lines: &[Vec], limits: &Limits) -> Result { let header = lines.first().ok_or_else(|| bad("missing image header"))?; let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?; let mut parts = parts.split_ascii_whitespace(); diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ac5fb39487..e1e0927fad 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -17,7 +17,7 @@ pub enum Format { Pnm, /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. Pcx, - /// X PixMap icons (XPM3 C-string representation). + /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, Qoi, OpenExr, @@ -296,7 +296,7 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } - if b.starts_with(b"/* XPM */") { + if b.starts_with(b"/* XPM */") || b.starts_with(b"! XPM2\n") || b.starts_with(b"! XPM2\r\n") { return Some(Format::Xpm); } if crate::codecs::pcx::looks_like_pcx(b) { diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/xpm.rs index 8727e5dbc3..aa0b3bf0b0 100644 --- a/crates/codecs/tests/xpm.rs +++ b/crates/codecs/tests/xpm.rs @@ -84,6 +84,36 @@ static char *icon[] = { assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]); } +#[test] +fn xpm2_decodes_crlf_comments_spaces_and_multichar_keys() { + let bytes = b"! XPM2\r\n! exported by an icon tool\r\n2 2 3 2\r\n.a c #f00\r\n!_ c None\r\nzz c DarkBlue\r\n.a!_\r\nzz.a\r\n"; + assert_eq!(detect(bytes), Some(Format::Xpm)); + let image = decode(bytes).unwrap(); + assert_eq!(image.dimensions(), (2, 2)); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 139, 255, 255, 0, 0, 255]); +} + +#[test] +fn xpm2_allows_exclamation_as_a_pixel_key_and_preserves_space_keys() { + let bytes = b"! XPM2\n2 1 2 1\n! c red\n c None\n! \n"; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0]); +} + +#[test] +fn xpm2_malformed_inputs_return_errors() { + for bytes in [ + &b"! XPM2\n1 1 1 1\na c black\n"[..], + &b"! XPM2\n1 1 1 1\na c black\nb\n"[..], + &b"! XPM2\n1 1 1 1\na c #a\xc3\xa9\na\n"[..], + &b"! XPM2 invalid\n1 1 1 1\na c black\na\n"[..], + &b"! XPM2\n100000 100000 1 1\na c red\na\n"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{bytes:?}"); + } + assert_ne!(detect(b"! XPM23\n1 1 1 1\na c black\na\n"), Some(Format::Xpm)); +} + #[test] fn xpm_is_import_only() { assert!(caps(Format::Xpm).read); From af9316b97e554eaf51e10ed85d23440e0eb184e3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 02:03:29 +0200 Subject: [PATCH 021/223] Docs: refresh gaps.md against main (#4412) (#4413) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- docs/gaps.md | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index d505e8f447..870613ae30 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-12 · **Change:** minor (G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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 @@ -67,8 +67,9 @@ what an agent can't finish alone. ### G7. Workspaces shown as parameter dialogs - **Missing:** Select and Mask workspace layout (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area - (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the - transform box (TOOL-214-7); Content-Aware Move's Transform On Drop (TOOL-213-4). + (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). - **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. @@ -108,7 +109,7 @@ what an agent can't finish alone. - **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. -- **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50. +- **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. - **Progress (2026-10-11):** on the embedded-JPEG rendering work in #2135, Camera Raw tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a @@ -153,23 +154,24 @@ what an agent can't finish alone. - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. ### G5. Transform, handles and modifiers -- **Missing:** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the - options bar (#2442); H/V skew fields and the full interpolation list; transform keeps layer - order and blend mode in its preview (#2439); Hand tool interrupting a transform (#2302); - blend mode/opacity edits during a transform (#1384); locked layers refused before the drag - (#2585); ⌘-drag temporary Move without box or snapping (#2681), Auto-Select box follows the - previous layer (#2666); ⌘-arrow layer nudge (#2474); path-point nudge; polygonal lasso Shift - angles (#2352); autoscroll at the edge while selecting (#2231); hold-a-key spring-loaded tools - (#2672); double-click Zoom tool to 100% (#2508); wheel and pinch behaviour (#2485, #2463, - #1989); toolbar press-and-hold flyouts (#2301); tool-key memory in a group (#2608). +- **Missing:** H/V skew fields and the full interpolation list; blend mode/opacity edits during + a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); + hold-a-key spring-loaded tools (#2672); wheel and pinch behaviour (#2485, #2463, #1989); + toolbar press-and-hold flyouts (#2301). +- **Done since the re-measure (2026-10-10 to 12):** right-click menu during Free Transform + (TOOL-214-5); commit/cancel buttons in the options bar (#2442); transform keeps the layer's + place in the stack (#2439, #2480); Hand tool no longer interrupts a transform (#2302, #2327); + locked layers refused before the drag (#2585, #2609); ⌘-drag temporary Move with box and + snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow + layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool + to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace -- **Missing:** 46 of 149 preferences do nothing (scorecard prefs audit); panel groups can't +- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); UI scale presets (UI-217-9, #2492); more themes (UI-217-11, - #2661); Stamp Visible (UI-217-22); layer clipboard + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. @@ -186,8 +188,7 @@ what an agent can't finish alone. loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, Content-Aware Scale 24 MP 954 s against 3 s, Select Subject 559 ms against 100 ms; the - 150-layer nudge crashes the GPU compositor (P12); a 50 MP PSB with feathered masks redraws in - ~10 s (#2377); menus slow with thousands of layers (#2460); old machines lag (#2687). + 150-layer nudge crashes the GPU compositor (P12); menus slow with thousands of layers (#2460); old machines lag (#2687). - **Estimate:** 200–350 h. Serial in places (compositor architecture). ## File-format gaps @@ -197,7 +198,7 @@ what an agent can't finish alone. (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). + .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -216,7 +217,7 @@ what an agent can't finish alone. ### G13. Hindi, Arabic, Vietnamese, RTL and engine messages - **Missing:** catalogs for Hindi, Arabic and Vietnamese (3 of the 12 key languages); right-to- - left UI layout; Devanagari/Thai/Khmer fallback fonts; engine error and status messages in + left UI layout; Devanagari fallback fonts (Thai #1909 and Khmer, Lao and Myanmar #3259 landed); engine error and status messages in English in every language; browser-locale detection and CJK fonts on the web; Japanese IME on Windows (#590); installed Japanese fonts missing from the font list (#2330); Arabic layer names as tofu (#1408, #1693). @@ -228,8 +229,8 @@ what an agent can't finish alone. ## Platform gaps ### G17. Platforms and distribution -- **Missing:** Windows code signing (material not obtained), Windows ARM64 (DIST-220-2), in-app - update check (DIST-220-1, #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), +- **Missing:** Windows code signing (material not obtained), in-app update check (DIST-220-1, + #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with OpenCore (#1859). - **Estimate:** 30–60 h. **Human:** signing certificates. @@ -241,6 +242,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 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 | 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 | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | From 13657f35f275e181586151aad040a8a1df4789bc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 02:04:19 +0200 Subject: [PATCH 022/223] Docs: refresh target-app-parity and file-format-parity against main (#4417) (#4418) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- docs/file-format-parity.md | 6 ++++-- docs/target-app-parity.md | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0aa2984e23..12c6dc9d1d 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-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` 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-12 · **Change:** minor (DPX read and write, #1049; 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 @@ -132,7 +132,8 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | ICC profiles | R | R | Own CMS | | Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | -| Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | +| Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | +| Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | @@ -142,6 +143,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. |---|---|---| | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | +| 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 | D5100 image area and fitted-look preservation in Camera Raw tuning | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5e8dd8a04..1322980b8b 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-10 · **Last updated:** 2026-10-12 · **Change:** minor (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; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (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; merged `parity-estimate.md` and the roadmap's honest assessment) · **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); @@ -49,7 +49,7 @@ architecture and the PSD writer are the serial parts. `Locales/` language packs, `Default Keyboard Shortcuts.kys` (67 toolbox tools, 107 menu commands with default shortcuts), `Required/` plug-ins (85 filter entries, 12 format plug-ins), presets, and strings in the main binary (81 "… Tool" titles; HDR/EDR, Metal, Touch Bar, pen features). -- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items) was +- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items on 2026-10-10, 631 on 2026-10-12) was written from Photoshop's menus; it is the denominator of the menu number. Photoshop's binary `Default Menus.mnu` was not parsed, so items added in 27.x that the catalog lacks would not show (Generative and cloud items are the likely ones). @@ -60,7 +60,7 @@ architecture and the PSD writer are the serial parts. | Component | Weight | Ours / Photoshop | % | Kind | |---|---:|---|---:|---| -| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | +| Menu commands | 30% | 631 / 631 catalog items dispatch a live command (628 / 628 on 2026-10-10) | 100% | measured (`cargo xtask parity`) | | Toolbox tools | 15% | 59 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 87% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | | Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | @@ -76,13 +76,13 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| | Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | -| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 5 / 7 / 11; 46 of 149 preferences do nothing; no floating panel groups, thin Color panel, no transform context menu, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | +| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 27 of 156 preferences do nothing (was 46 of 149); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | | Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | -| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | -| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | +| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | +| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); no in-app updates | [scorecard.md](scorecard.md#distribution) | | Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -114,7 +114,7 @@ Discounts for what still stops real work (estimated, each with evidence): | Discount | Factor | Evidence | |---|---:|---| -| Interaction fidelity | ×0.92 | ~150 open UI issues; transform context menu, spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | +| Interaction fidelity | ×0.92 | ~150 open UI issues; spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | | Stability and speed on real machines | ×0.88 | 3 of 25 performance budgets met; 150-layer GPU crash (P12); Type tool freeze (#2518); GPU start failures (#2519, #2412, #2020); 41 open performance issues | | Exchanging files with Photoshop users | ×0.92 | Photoshop-authored oracle 133/256; exports refused by Photoshop (#1281, #2469); smart-filter re-render 5/30 | @@ -144,7 +144,7 @@ workflows, PSD exchange edge cases, and everything excluded above. | Layers (add, reorder, hide, opacity) | 12% | 80% | | | New / open a photo | 10% | 90% | | | Select (marquee, lasso, magic wand, object) | 10% | 75% | | -| Move and Free Transform | 10% | 70% | no commit/cancel buttons (#2442) | +| Move and Free Transform | 10% | 70% | commit and cancel buttons in the options bar (#2442, closed) | | Brush and eraser | 10% | 70% | lag with big brushes (#2619) | | Undo, History | 8% | 90% | | | Basic adjustments (brightness, levels, curves, hue/saturation) | 8% | 80% | | @@ -167,14 +167,14 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) | -| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | -| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | +| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5); no 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). Missing anchor tools, on-canvas corner radius, 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); missing on-canvas corner radius (TOOL-214-12), 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 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -564,6 +564,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | +| 2026-10-12 | minor | Rows checked against `main` and the regenerated scorecard: menu count 631, Workspace checklist and preference count, transform context menu, Puppet Warp mesh, Select and Mask, anchor-point tools, Smart Object conversion, Stamp Visible, mask targeting, Free Transform commit/cancel, Vietnamese catalog, Windows ARM64; percentages, weights and hours not re-measured | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | From 776b9dddd089333a8c10f2f8e6be6b9fbaf23cd2 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Tue, 13 Oct 2026 08:05:09 +0800 Subject: [PATCH 023/223] Clone Stamp and History Brush: releasing a stroke commits the live pixels (#4019) Releasing a Clone Stamp or History Brush stroke rendered the whole stroke again, although the live stroke had already drawn exactly those pixels. The canvas now offers the live stroke to its commit as the Brush does (#2131), through the same LiveCommit: a commit with the same command, document revision, params and points takes the live surface and reports fromLive; anything else renders as before. LiveRetouch renders only the position and pressure of its points, which is all the commands parse, and the offer is refused when the brush, layer or clone source changed during the stroke (the revision doesn't cover them). The Healing Brush, which blends on release, is left out. --- crates/engine/src/brush_cmds.rs | 28 ++--- crates/engine/src/lib.rs | 9 ++ crates/engine/src/retouch_cmds.rs | 71 ++++++++++++- crates/engine/src/retouch_cmds/tests.rs | 122 ++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 30 +++++- crates/ui-egui/src/stroke_timing_tests.rs | 46 ++++++++ 6 files changed, 287 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 321973b8d2..e66e18b94e 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -437,17 +437,19 @@ pub struct LiveStroke { base: (photocraft_doc::DocId, u64), } -/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`]): when the -/// committing `paint.stroke` / `paint.pencil` matches it (same command, document revision, params, -/// seed and points), the command takes these pixels instead of rendering the stroke again. +/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`], +/// [`Session::offer_live_retouch`]): when the committing command matches it (same command, +/// document revision, params, seed and points), it takes these pixels instead of rendering the +/// stroke again. pub struct LiveCommit { - cmd: String, - base: (photocraft_doc::DocId, u64), - params: Value, - seed: u64, - points: Vec, - surface: Surface, - bounds: Rect, + pub(crate) cmd: String, + pub(crate) base: (photocraft_doc::DocId, u64), + pub(crate) params: Value, + /// The Brush's and Pencil's seed; the retouching tools have none. + pub(crate) seed: Option, + pub(crate) points: Vec, + pub(crate) surface: Surface, + pub(crate) bounds: Rect, } impl LiveCommit { @@ -461,11 +463,11 @@ impl LiveCommit { p } - fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { + pub(crate) fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { let base = s.active().map(|st| (st.doc.id, st.revision)); self.cmd == cmd && base == Some(self.base) - && p.get("seed").and_then(Value::as_u64) == Some(self.seed) + && p.get("seed").and_then(Value::as_u64) == self.seed && self.points == pts && Self::settings(p) == Self::settings(&self.params) } @@ -531,7 +533,7 @@ impl LiveStroke { pub fn into_commit(mut self) -> Result { let bounds = self.bounds(); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: self.seed, points: self.points, surface: surf.clone(), bounds }) + Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds }) } /// Everything the stroke has touched so far. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27a2f941a8..45cf3418fe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -567,6 +567,15 @@ impl Session { Ok(()) } + /// [`Self::offer_live_stroke`] for a live Clone Stamp or History Brush stroke, taken by the + /// `paint.cloneStamp` / `paint.historyBrush` it was drawn for. Refused for the Healing Brush + /// (it blends on release) and when the brush, layer or clone source changed during the stroke. + pub fn offer_live_retouch(&mut self, live: retouch_cmds::LiveRetouch) -> Result<()> { + let commit = live.into_commit(self)?; + self.live_commit = Some(commit); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 833e2db62e..cba7a22976 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -297,15 +297,27 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); + // The live stroke the canvas already drew for exactly this commit: take its pixels instead of + // rendering the stroke again (the hitch on release). + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let (bounds, cov) = stroke_coverage(&stroke); // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(clone_result(dmg, off, aligned, p, &stroke)) + let mut r = clone_result(dmg, off, aligned, p, &stroke); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } /// What a live retouching stroke paints through its coverage. @@ -337,6 +349,11 @@ pub struct LiveRetouch { /// Where the doc shows what finishing the stroke now would add (a lone first dab, the /// smoothing catch-up), redrawn on every step as the Brush's live stroke does. tail: Rect, + /// The document and revision the stroke started on. + base: (photocraft_doc::DocId, u64), + brush: Box, + /// Every point rendered so far, in order. + points: Vec, } impl LiveRetouch { @@ -358,7 +375,8 @@ impl LiveRetouch { "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), }; - let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let st = s.active().ok_or(EngineError::NoDocument)?; + let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision)); let mut doc = (*pre).clone(); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; @@ -366,6 +384,7 @@ impl LiveRetouch { // As `stroke_coverage`, which the commit uses. let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); let opacity = stroke.brush.opacity; + let brush = Box::new(stroke.brush.clone()); let paint = match paint { LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())), other => other, @@ -384,6 +403,9 @@ impl LiveRetouch { sel, lock, tail: Rect::EMPTY, + base, + brush, + points: Vec::new(), }; live.push(&stroke.points)?; Ok(live) @@ -394,15 +416,46 @@ impl LiveRetouch { self.renderer.bounds().union(&self.tail) } + /// Every point rendered so far, in order. + pub fn points(&self) -> &[StrokePoint] { + &self.points + } + + /// The finished stroke as its commit would take it over ([`Session::offer_live_retouch`]). + /// Only the Clone Stamp and History Brush draw what they commit (the Healing Brush blends on + /// release), and only while the brush, layer and clone source are still the ones the stroke + /// started with: the document revision doesn't cover those. + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?; + let source_unchanged = match &self.paint { + LivePaint::Clone { map, .. } if self.cmd == "paint.cloneStamp" => { + let f = stroke.points.first().ok_or_else(|| bad(&self.cmd, "`points` is empty"))?; + crate::presets::clone_source::resolve(s, &self.params, (f.x, f.y), &self.cmd)?.0 == *map + } + LivePaint::History(_) if self.cmd == "paint.historyBrush" => true, + _ => return Err(bad(&self.cmd, "only Clone Stamp and History Brush strokes commit their live pixels")), + }; + if !source_unchanged || *self.brush != stroke.brush || id != self.id { + return Err(bad(&self.cmd, "the brush, layer or clone source changed during the stroke")); + } + let bounds = self.bounds(); + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + let surface = surf.clone(); + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds }) + } + /// Render more points; returns the rectangle that changed. Each changed area is redrawn from /// the pre-stroke pixels with the stroke's coverage so far, so overlapping dabs build up as in /// the commit, and pixels painted earlier in the stroke are never re-cloned. What finishing /// the stroke now would add is drawn too (a lone first dab shows on the press), and redrawn on /// every step. /// Invalid coordinates reject the whole batch without changing the preview. + /// Only the position and pressure of `pts` are used: the commands take no tilt or times. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { crate::brush_cmds::check_coords(pts, &self.cmd)?; - self.renderer.push(pts); + let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); + self.points.extend_from_slice(&pts); + self.renderer.push(&pts); let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; if !old.is_empty() { @@ -519,15 +572,25 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(json!({ "damage": damage_json(dmg) })) + let mut r = json!({ "damage": damage_json(dmg) }); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index a0fac91b2a..b566196738 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1640,3 +1640,125 @@ fn remove_to_a_new_layer_leaves_the_target_untouched() { assert!(near(d.doc.layer(nid).unwrap().surface().unwrap().rgba(46, 30), bg, 0.03)); assert!(near(d.doc.layer(target).unwrap().surface().unwrap().rgba(46, 30), square, 0.001)); } + +/// A Clone Stamp or History Brush stroke drawn live and offered to its commit: the commit takes +/// the live pixels, and they are what running the command afresh paints, bit for bit, at every +/// depth, with and without a feathered selection. +#[test] +fn a_live_clone_or_history_stroke_commits_its_own_pixels() { + let all = [[30.0, 30.0], [50.0, 34.0], [70.0, 28.0], [90.0, 40.0], [60.0, 50.0], [35.0, 44.0], [36.0, 45.0]]; + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + for depth in DEPTHS { + for (cmd, extra) in [("paint.cloneStamp", json!({"source": [20, 15], "mode": "multiply"})), ("paint.historyBrush", json!({"state": 1}))] { + for selected in [false, true] { + let setup = || { + let mut s = session(120, 70, depth, "rgb"); + paint_layer(&mut s, texture); + s.execute("paint.stroke", json!({"points": [[0, 40], [120, 40]], "size": 20, "hardness": 1.0, "color": "#c03030"})).unwrap(); + if selected { + s.execute("select.rect", json!({"x": 40, "y": 20, "width": 50, "height": 40, "ellipse": true, "feather": 3})).unwrap(); + } + s + }; + let params = |v: &[[f64; 2]]| { + let mut p = json!({"points": v, "size": 14, "hardness": 40, "opacity": 70}); + for (k, x) in extra.as_object().unwrap() { + p[k] = x.clone(); + } + p + }; + let mut s = setup(); + let mut live = LiveRetouch::begin(&s, cmd, ¶ms(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..4])).unwrap(); + live.push(&pts(&all[4..])).unwrap(); + s.offer_live_retouch(live).unwrap(); + let r = s.execute(cmd, params(&all)).unwrap(); + s.clear_live_stroke(); + assert_eq!(r["fromLive"], true, "{cmd} {depth}-bit selected={selected}"); + let mut fresh = setup(); + let r = fresh.execute(cmd, params(&all)).unwrap(); + assert!(r.get("fromLive").is_none()); + let (a, b) = (s.active().unwrap(), fresh.active().unwrap()); + let bounds = a.doc.bounds(); + let got = a.doc.layer(a.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let want = b.doc.layer(b.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let diff = got.iter().zip(&want).position(|(x, y)| x.to_bits() != y.to_bits()); + assert!(diff.is_none(), "{cmd} {depth}-bit selected={selected}: first difference at value {diff:?}"); + assert_eq!(a.history.past_len(), b.history.past_len()); + } + } + } +} + +/// An offered live stroke is taken only by the commit it was drawn for, and only once. +#[test] +fn a_live_retouch_offer_is_taken_only_by_its_own_commit() { + let mut s = session(96, 64, 8, "rgb"); + paint_layer(&mut s, texture); + let all = [[50.0, 30.0], [60.0, 34.0], [70.0, 30.0]]; + let params = |v: &[[f64; 2]], opacity: u32| json!({"points": v, "source": [20, 20], "size": 12, "hardness": 50, "opacity": opacity}); + let offer = |s: &mut Session| { + let live = LiveRetouch::begin(s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.offer_live_retouch(live).unwrap(); + }; + let from_live = |r: Value| r.get("fromLive").is_some(); + // Other points, other params, another command: rendered afresh. + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all[..2], 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 60)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + let mut history = params(&all, 70); + history.as_object_mut().unwrap().remove("source"); + assert!(!from_live(s.execute("paint.historyBrush", history).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + // The document changed after the stroke started. + offer(&mut s); + s.execute("paint.stroke", json!({"points": [[5, 5], [9, 9]], "size": 4})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // A withdrawn offer, and an offer used once. + offer(&mut s); + s.clear_live_stroke(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + // The Healing Brush blends on release: it has no live pixels to offer. + let healing = LiveRetouch::begin(&s, "paint.healingBrush", ¶ms(&all, 70)).unwrap(); + assert!(s.offer_live_retouch(healing).is_err()); + // The brush changed during the stroke (the document revision doesn't show it). + let live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"spacing": 0.5}})).unwrap(); + assert!(s.offer_live_retouch(live).is_err()); +} + +/// The live stroke renders only the position and pressure of its points, as its commit parses +/// them: a pen's tilt and the times don't change what it shows. +#[test] +fn a_live_retouch_stroke_ignores_tilt_and_times() { + let mut s = session(64, 48, 8, "rgb"); + paint_layer(&mut s, texture); + let p = json!({"points": [[20, 20]], "source": [8, 8], "size": 10, "hardness": 50}); + let mut plain = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let mut pen = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let pts = [[30.0, 24.0], [44.0, 30.0]]; + plain.push(&pts.map(|q| StrokePoint::new(q[0], q[1], 0.8))).unwrap(); + pen.push(&pts.map(|q| { + let mut sp = StrokePoint::new(q[0], q[1], 0.8); + (sp.tilt_x, sp.tilt_y, sp.rotation, sp.time) = (40.0, -20.0, 90.0, 120.0); + sp + })) + .unwrap(); + assert_eq!(plain.points(), pen.points()); + let id = s.active().unwrap().active_layer.unwrap(); + let b = s.active().unwrap().doc.bounds(); + let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b); + assert_eq!(read(&plain), read(&pen)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index af3a11f04d..3c067c7565 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -680,6 +680,28 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { } } +/// The points the live retouching stroke has rendered. +#[cfg(test)] +pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option> { + match &app.live_stroke.as_ref()?.stroke { + EngineStroke::Retouch(l) => Some(l.points().to_vec()), + _ => None, + } +} + +/// Offer the Clone Stamp's or History Brush's live stroke, with the drag points it hasn't rendered +/// yet, to the command `retouch_ui::finish_stroke` commits, which then takes its pixels. +fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { + if !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) { + return; + } + let Some(LiveStroke { stroke: EngineStroke::Retouch(mut live), fed, .. }) = app.live_stroke.take() else { return }; + let rest: Vec<_> = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect(); + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_retouch(live); + } +} + /// A pen tap (a drag still at its one press point when released) paints its dab at the highest /// pressure the pen reached during the contact, not just the first sample's (#1798). A mouse is /// unchanged at full pressure. @@ -5164,11 +5186,15 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { { app.last_stroke_end = Some((st.doc.id, end)); } - // A live Clone Stamp preview ends here; the commit below replaces it. + // A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and + // History Brush's with the pixels it already drew). + offer_live_retouch(app, &d); if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; } - if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { + let finished = crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers); + app.session.clear_live_stroke(); + if finished { return; } match d.tool { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 5c369370ff..fc6d0c7e78 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -337,3 +337,49 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(0), Some(r(0).union(&r(20)).union(&r(40))), "never showed the stroke: all of it"); assert_eq!(c.since(99), None, "not a key of this stroke"); } + +/// A Clone Stamp stroke through the canvas renders the points its commit gets (position and +/// pressure, no tilt or times), so the commit can take the live pixels; they are what replaying +/// the journaled command paints. +#[test] +fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.4}})); + run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"})); + run("cloneSource.set", json!({"source": [40, 30]})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + h.state_mut().ui.tool = Tool::CloneStamp; + h.run_steps(1); + move_to(&mut h, 60.0, 50.0); + button(&mut h, 60.0, 50.0, true); + for (x, y) in [(80.0, 40.0), (110.0, 60.0), (140.0, 45.0), (170.0, 65.0)] { + move_to(&mut h, x, y); + } + let shown = crate::canvas::live_retouch_points(h.state()).expect("a live Clone Stamp stroke"); + button(&mut h, 170.0, 65.0, false); + h.run_steps(2); + let app = h.state(); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.cloneStamp").cloned().expect("a committed Clone Stamp stroke"); + let committed: Vec<_> = p["points"] + .as_array() + .unwrap() + .iter() + .map(|q| photocraft_engine::paint::StrokePoint::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap(), q[2].as_f64().unwrap() as f32)) + .collect(); + assert!(shown.len() > 1 && committed.starts_with(&shown), "live {shown:?} vs committed {committed:?}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute("paint.cloneStamp", p).unwrap(); + let st = app.session.active().unwrap(); + let a = photocraft_compose::render(&st.doc, st.doc.bounds()); + let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()); + assert_eq!(a.px.len(), b.px.len()); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ"); +} From dbadd21a3e9d74bb040d0175053d03753e9f3492 Mon Sep 17 00:00:00 2001 From: Cyrill Vitkovskiy <4040177+Furash@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:06:11 +0100 Subject: [PATCH 024/223] Stylus: a pen held still is not a press-and-hold right click (#4106) * Stylus: a pen held still is not a press-and-hold right click On Windows a pen reaches egui as touch events, and egui takes any touch contact held still past max_click_duration (0.8 s) for a touch screen's press-and-hold: a secondary click (context menu) that also drops the drag. Holding the pen still for a second opened a context menu. PenContacts tells a pen contact from a finger in raw_input_hook (only a pen hovers, and a pen reports pressure) and turns the press-and-hold timeout off while one is down, through the frame it lifts in, so a long still press releases as a tap. Fingers keep press-and-hold. Co-Authored-By: Claude Opus 5.5 * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/stylus.rs | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 73cc9b61d0..05c49b60d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1673,6 +1673,9 @@ impl eframe::App for PhotocraftApp { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + // A pen held still is no touch screen's press-and-hold right click (stylus.rs). + let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); + stylus::pen_hold_is_no_right_click(ctx, pen_down); if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index 110f401f63..8460c31ea2 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -137,6 +137,8 @@ pub struct Stylus { current: Option, /// The highest pen pressure (before the curve) since the drag began, for a tap's one dab. peak: f32, + /// The pen's touch contacts in the raw input, so a held pen is no press-and-hold right click. + pub(crate) pen_contacts: PenContacts, /// The egui frame [`Self::update_for_frame`] last ran in. updated_frame: Option, /// Tilt X, tilt Y, rotation of each point of the current drag (parallel to its points). @@ -166,6 +168,7 @@ impl Default for Stylus { prev: None, current: None, peak: 0.0, + pen_contacts: PenContacts::default(), updated_frame: None, stroke: Vec::new(), times: Vec::new(), @@ -416,10 +419,143 @@ impl Stylus { } } +/// The touch contacts a pen makes, read from the raw input. egui takes every touch contact held +/// still past `max_click_duration` (0.8 s) for a touch screen's press-and-hold: a secondary click +/// (a context menu) that also drops the drag. On Windows a pen arrives as touch events, so a pen +/// held still opened a context menu. A pen held still is just a pen, as in Photoshop. +#[derive(Clone, Debug, Default)] +pub(crate) struct PenContacts { + /// Touch ids that moved without touching: only a pen hovers. + hovering: Vec, + /// The contacts down, and whether each is a pen. + down: Vec<(egui::TouchId, bool)>, +} + +/// Most touch ids [`PenContacts`] remembers (the oldest go first). +const CONTACTS: usize = 16; + +impl PenContacts { + /// Follow a frame's touch events. True when a pen contact was down at any point of the frame. + pub(crate) fn observe(&mut self, events: &[egui::Event]) -> bool { + let pressed = |f: &Option| f.is_some_and(|f| f > 0.0); + let mut pen = self.down.iter().any(|(_, p)| *p); + for e in events { + let egui::Event::Touch { id, phase, force, .. } = e else { continue }; + match phase { + egui::TouchPhase::Start => { + let is_pen = self.hovering.contains(id) || pressed(force); + self.down.retain(|(c, _)| c != id); + if self.down.len() >= CONTACTS { + self.down.remove(0); + } + self.down.push((*id, is_pen)); + pen |= is_pen; + } + egui::TouchPhase::Move => match self.down.iter_mut().find(|(c, _)| c == id) { + Some(c) if pressed(force) => { + c.1 = true; + pen = true; + } + Some(_) => {} + None if !self.hovering.contains(id) => { + if self.hovering.len() >= CONTACTS { + self.hovering.remove(0); + } + self.hovering.push(*id); + } + None => {} + }, + egui::TouchPhase::End | egui::TouchPhase::Cancel => self.down.retain(|(c, _)| c != id), + } + } + pen + } +} + +/// Turn egui's touch press-and-hold off while a pen contact is down (and through the frame it +/// lifts in, so the release is the click a quick tap would be), back on otherwise for fingers. +pub(crate) fn pen_hold_is_no_right_click(ctx: &egui::Context, pen_down: bool) { + let want = if pen_down { f64::INFINITY } else { egui::InputOptions::default().max_click_duration }; + if ctx.options(|o| o.input_options.max_click_duration) != want { + ctx.options_mut(|o| o.input_options.max_click_duration = want); + } +} + #[cfg(test)] mod tests { use super::*; + /// Hold a touch contact still for 1.2 s over a click-sensing area, with the press-and-hold + /// handling the app does in `raw_input_hook`. Did the area see a secondary click? + fn held_contact_right_clicks(hover_first: bool, force: Option) -> bool { + let ctx = egui::Context::default(); + let mut contacts = PenContacts::default(); + let mut right_clicked = false; + let ev = |phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(3), phase, pos: egui::pos2(50.0, 50.0), force }; + // A first frame lays the area out, so the press hits it. + let mut frames = vec![(0.0, vec![])]; + if hover_first { + frames.push((0.05, vec![ev(egui::TouchPhase::Move, None), egui::Event::PointerMoved(egui::pos2(50.0, 50.0))])); + } + frames.push(( + 0.1, + vec![ + ev(egui::TouchPhase::Start, force), + egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), + egui::Event::PointerButton { + pos: egui::pos2(50.0, 50.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + )); + for t in [0.4, 0.7, 1.0, 1.3] { + frames.push((t, vec![])); + } + for (time, mut events) in frames { + // egui-winit emits the touch and its emulated pointer events together. + let mut raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0))), + ..Default::default() + }; + raw.events.append(&mut events); + let pen = contacts.observe(&raw.events); + pen_hold_is_no_right_click(&ctx, pen); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)), egui::Sense::click_and_drag()); + right_clicked |= r.secondary_clicked(); + }); + out.textures_delta.clear(); + } + right_clicked + } + + #[test] + fn a_held_pen_is_not_a_right_click_but_a_held_finger_still_is() { + assert!(held_contact_right_clicks(false, None), "a finger's press-and-hold is egui's context menu"); + assert!(!held_contact_right_clicks(true, None), "a pen that hovered first"); + assert!(!held_contact_right_clicks(false, Some(0.4)), "a contact that reports pressure"); + } + + #[test] + fn pen_contacts_follow_hover_force_and_lift() { + use egui::TouchPhase::{Cancel, End, Move, Start}; + let t = |id, phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos: egui::pos2(1.0, 1.0), force }; + let mut c = PenContacts::default(); + assert!(!c.observe(&[t(1, Move, None)]), "hovering is no contact"); + assert!(c.observe(&[t(1, Start, None)])); + assert!(c.observe(&[]), "still down"); + assert!(c.observe(&[t(1, End, None)]), "the lift frame still counts"); + assert!(!c.observe(&[])); + // A finger, until it reports a force. + assert!(!c.observe(&[t(9, Start, None), t(9, Move, None)])); + assert!(c.observe(&[t(9, Move, Some(0.5))])); + assert!(c.observe(&[t(9, Cancel, None)])); + assert!(!c.observe(&[])); + } + fn touch(phase: egui::TouchPhase, force: Option) -> egui::Event { egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(0), phase, pos: egui::pos2(1.0, 1.0), force } } From ef937adf9891e10848f575c4352f5f7cd34ae714 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 11:06:24 +1100 Subject: [PATCH 025/223] Refresh Liquify's zoomed preview when a stroke is undone. (#4135) * Refresh Liquify's zoomed preview when a stroke is undone. Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom. Refs #4100 * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/liquify_ui.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 2f429e6bcd..0bc4cbd782 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -286,6 +286,9 @@ impl LiquifyDialog { fn rebuild(&mut self) { self.field = LiquifyField::from_strokes(self.canvas, self.cell, &self.strokes); self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]); + // Undo rebuilds the whole field. The zoomed viewport is a separate buffer; leaving it + // clean kept the pre-undo pixels on screen until the next zoom (#4100). + self.view_dirty = self.view_bounds; self.mask_dirty = true; self.render_dirty(); } @@ -1210,6 +1213,27 @@ mod tests { assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field"); } + #[test] + fn undo_dirties_the_zoomed_preview() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); + for ev in [ToolEvent::Down { x: 35.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 55.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 55.0, y: 40.0 }] + { + pointer(&mut app, ev, egui::Modifiers::NONE); + } + { + let d = app.distort.liquify.as_mut().unwrap(); + d.zoom = 2.0; + d.view_bounds = Some(Rect::new(0, 0, 20, 20)); + d.view_dirty = None; + } + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.view_dirty, d.view_bounds); + } + #[test] fn liquify_redo_restores_undone_stroke_and_a_new_stroke_clears_redo() { let ctx = egui::Context::default(); From 799f28167fe502cef6be5a37ba8c08d26848189a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:10:27 +0700 Subject: [PATCH 026/223] fix(selection): avoid panics when sampling incomplete region masks Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping. Validated with the full algo suite, independent native and Wasm regressions, and green PR checks. --- crates/algo/src/selection.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ebe8a42701..268826f395 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -245,7 +245,9 @@ impl Region { if !self.bbox.contains(x, y) { return 0.0; } - self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0 + let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize); + let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0); + f32::from(value) / 255.0 } } @@ -1400,6 +1402,20 @@ mod tests { .collect() } + #[test] + fn region_coverage_outside_a_short_mask_is_zero() { + let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] }; + assert_eq!(region.at(-2, -3), 1.0); + assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(region.at(0, -3), 0.0); + assert_eq!(region.at(-2, -2), 0.0); + assert_eq!(region.at(-3, -3), 0.0); + + let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] }; + assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0); + assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0); + } + #[test] fn wand_region_matches_reference_magic_wand() { let (w, h) = (37, 29); From 2338492b1289181c661bf895311411caeb8b3a98 Mon Sep 17 00:00:00 2001 From: Hevar Hemin <66733462+Hevarh1@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:11:42 +0300 Subject: [PATCH 027/223] Type: Space inserts a space while editing type (#4224) * Type: Space inserts a space while editing type Space was grabbing the Hand tool during canvas type edit even though Photoshop keeps Space typing a space. Skip temporary-tool holds when ui.text_edit is active. * Credit @Hevarh1 with sync_github_name in About * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 +++ crates/ui-egui/src/hold_keys.rs | 7 ++++--- crates/ui-egui/src/hold_keys/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 9377474c8e..fadd139846 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -133,3 +133,6 @@ sync_github_name = true [people.lucAmbr0] sync_github_name = true + +[people.Hevarh1] +sync_github_name = true diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index c182247bfd..3f2845cae8 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -147,9 +147,10 @@ fn held(i: &InputState, sc: &KeyboardShortcut) -> bool { } /// The temporary tool whose key is held now, the most specific binding first (⌘Space before -/// Space). Never while a text field has the keyboard. +/// Space). Never while a text field has the keyboard, or while editing type on the canvas +/// (Space must insert a space, not grab the Hand tool). pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option { - if ctx.text_edit_focused() { + if ctx.text_edit_focused() || app.ui.text_edit.is_some() { return None; } let count = |sc: &KeyboardShortcut| { @@ -194,7 +195,7 @@ pub fn erase_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { /// Is the reposition key (the Hand key, any modifiers) down? pub fn reposition_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { - !ctx.text_edit_focused() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) + !ctx.text_edit_focused() && app.ui.text_edit.is_none() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) } /// Tools whose in-progress drag the reposition key moves. diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index 45e806c64d..d558a5a7b7 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -174,6 +174,28 @@ fn space_is_a_temporary_hand_and_gives_the_tool_back() { assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); } +#[test] +fn space_while_editing_type_is_not_the_hand_tool() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"text": "ئا", "size": 20, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 2, + anchor: 2, + session: "t".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + key(&mut h, Key::Space, true, Modifiers::NONE); + assert!(h.state().ui.text_edit.is_some(), "type session stays open"); + assert_eq!(held_tool(h.state(), &h.ctx), None, "Space types a space while editing"); + assert!(!reposition_held(h.state(), &h.ctx)); + key(&mut h, Key::Space, false, Modifiers::NONE); +} + #[test] fn temporary_zoom_in_and_out_restore_the_tool() { let mut h = harness(Tool::RectMarquee); From 0761234c2f4dc9c094f5f74363c757d0692d7d93 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:33 +0700 Subject: [PATCH 028/223] fix(algo): reject oversized Magic Eraser masks without overflow (#4043) * fix(algo): reject oversized Magic Eraser masks before multiplying dimensions * Format the oversized Magic Eraser mask test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/erase.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/erase.rs b/crates/algo/src/erase.rs index 6f644d1191..75b2a4246c 100644 --- a/crates/algo/src/erase.rs +++ b/crates/algo/src/erase.rs @@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 }; let bbox = region.bbox; let bw = bbox.width() as usize; - if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize { + let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY }; + if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels { return Rect::EMPTY; } let fmt = target.format(); @@ -147,6 +148,16 @@ mod tests { assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]); } + #[test] + fn oversized_region_mask_is_rejected_without_overflow() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + let before = s.pixel(0, 0); + let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] }; + assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY); + assert_eq!(s.pixel(0, 0), before); + } + #[test] fn degenerate_inputs_do_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); From 9712f5e7d59027bee58823fbbe8f6b877a9b1837 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:41 +0700 Subject: [PATCH 029/223] fix(selection): reject incomplete magic wand pixel buffers (#4046) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/selection.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 268826f395..d6662e75f1 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { /// around the seed. `px` covers `area`. pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec { let (w, h) = (area.width() as usize, area.height() as usize); - let mut m = vec![0.0f32; w * h]; + let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() }; + let mut m = vec![0.0f32; n]; if !area.contains(seed.0, seed.1) { return m; } @@ -259,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 return None; } let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > img.len()) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -1402,6 +1406,23 @@ mod tests { .collect() } + #[test] + fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() { + let area = Rect::new(0, 0, 4, 4); + let short = vec![[255u8, 0, 0, 255]; 4]; + let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::::new()); + assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None); + + let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX); + assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty()); + assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None); + + let valid = Rect::new(0, 0, 2, 2); + assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]); + assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some()); + } + #[test] fn region_coverage_outside_a_short_mask_is_zero() { let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] }; From d3d735c6c77362b872a3b48deb290e072318a3c1 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 01:16:49 +0100 Subject: [PATCH 030/223] feat(ui): the shaped brush cursor turns and flips with the view (#4233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3467 traces a shaped tip's outline but scales it by the screen radius alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the cursor kept the document's orientation while the stroke did not. The outline now goes on screen through the view's own axes: ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip, zoom), and brush_cursor::tip_axes gives one tip radius along the document's x and y. A plain view gives the same points as before. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/brush_cursor.rs | 78 ++++++++++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 15 +++++- docs/brush-parity.md | 5 +- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 2cb30806aa..c53fc5de6b 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -42,11 +42,25 @@ impl Key { } } -/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom); -/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). -pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon { +/// The screen vectors of one tip radius (`size / 2` document pixels) along the document's x and +/// y axes: the view's zoom, rotation, flip and pixel aspect, which the outline follows as the +/// stroke does. +pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] { + let r = if size.is_finite() { size.max(0.0) / 2.0 } else { 0.0 }; + [xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))] +} + +/// Rings in radius units (document orientation) on screen, through [`tip_axes`]. +fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { + rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect() +} + +/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom), +/// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is +/// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). +pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon { if let Some(rings) = rings(painter.ctx(), brush, !normal) { - let pts: Vec> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect(); + let pts = on_screen(&rings, axes); let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); if extent < MIN_RADIUS { return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0); @@ -73,3 +87,59 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option ViewXform { + ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), zoom: 2.0, center: [50.0, 40.0], flip, rotation, aspect } + } + + fn close(a: Vec2, b: Vec2) -> bool { + (a - b).length() < 1e-4 + } + + /// The axes are the screen offsets of the document points one radius right of and below the + /// pointer, whatever the view does. + #[test] + fn tip_axes_follow_the_view_like_to_screen() { + for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, false, 1.0), (-90.0, true, 1.0), (45.0, true, 2.0), (200.0, false, 0.5)] { + let xf = view(rotation, flip, aspect); + let [ex, ey] = tip_axes(&xf, 20.0); + let o = xf.to_screen(60.0, 30.0); + assert!(close(ex, xf.to_screen(70.0, 30.0) - o), "x axis at {rotation}°, flip {flip}, aspect {aspect}"); + assert!(close(ey, xf.to_screen(60.0, 40.0) - o), "y axis at {rotation}°, flip {flip}, aspect {aspect}"); + } + } + + /// An unrotated, unflipped view gives what upstream's radius scaling gave. + #[test] + fn a_plain_view_scales_the_outline_by_the_screen_radius() { + let rings = vec![vec![[1.0, 0.0], [0.0, 0.5], [-1.0, 0.0]]]; + let pts = on_screen(&rings, tip_axes(&view(0.0, false, 1.0), 20.0)); + let want = [Vec2::new(20.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(-20.0, 0.0)]; + assert!(pts[0].iter().zip(want).all(|(a, b)| close(*a, b)), "{pts:?}"); + } + + /// A tip pointing along the document's x axis points down the screen in a view turned 90°, + /// and left in a flipped one. + #[test] + fn the_outline_turns_and_flips_with_the_view() { + let rings = vec![vec![[1.0, 0.0]]]; + let turned = on_screen(&rings, tip_axes(&view(90.0, false, 1.0), 20.0)); + assert!(close(turned[0][0], Vec2::new(0.0, 20.0)), "{turned:?}"); + let flipped = on_screen(&rings, tip_axes(&view(0.0, true, 1.0), 20.0)); + assert!(close(flipped[0][0], Vec2::new(-20.0, 0.0)), "{flipped:?}"); + } + + #[test] + fn hostile_sizes_give_finite_axes() { + for size in [f32::NAN, f32::INFINITY, -5.0, 0.0] { + let [ex, ey] = tip_axes(&view(30.0, true, 1.0), size); + assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3c067c7565..b053af1a41 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -864,6 +864,15 @@ impl ViewXform { vec2((d.x * c + d.y * s) / self.sx(), -d.x * s + d.y * c) } + /// A document-space vector on screen: stretched, rotated, flipped and zoomed as the view + /// draws it (the inverse of [`Self::unmap_vec`], with the zoom). + pub fn map_vec(self, d: Vec2) -> Vec2 { + let (c, s) = self.sincos(); + let dx = d.x * self.sx(); + let v = vec2(dx * c - d.y * s, dx * s + d.y * c) * self.zoom; + if self.flip { vec2(-v.x, v.y) } else { v } + } + pub fn to_screen(&self, x: f32, y: f32) -> Pos2 { let (c, s) = self.sincos(); let dx = (x - self.center[0]) * self.sx(); @@ -3575,10 +3584,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: egui::CursorIcon::None } // A shaped tip (sampled bitmap, or a rotated/flattened round tip) - // traces its outline; a plain round tip keeps the circle. + // traces its outline, turned and flipped with the view; a plain round + // tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); - crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre) + let axes = crate::brush_cursor::tip_axes(&xf, brush.size); + crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre) } } } diff --git a/docs/brush-parity.md b/docs/brush-parity.md index ad1ea5c83c..4022f7a657 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-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **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 @@ -55,7 +55,7 @@ tablets against Photoshop side by side. | Interpolation and smoothing match Photoshop | partial | #2138, #2057 | | Soft-edge falloff matches Photoshop's curve | partial | #2149, #1619 | | Brush preview and cursor crisp, not pixelated | partial | #1433 | -| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364 | +| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364; shaped tips trace their outline (#3467), turned and flipped with a rotated or flipped view | | Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | @@ -116,3 +116,4 @@ tablets against Photoshop side by side. | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | +| 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | From 003f9b4a1867af1abdf60b11dbd2a513f2f7e48d Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 12 Oct 2026 17:16:56 -0700 Subject: [PATCH 031/223] Affinity import: pie shapes with huge angles no longer hang the reader (#4054) * Affinity import: pie shapes with huge angles no longer hang the reader The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a loop until the end angle reached the start angle. Both angles come from the file. The loop ran about `(AngS - AngE) / TAU` times, and it never ended once adding `TAU` no longer changed the end angle: with `AngS` = 1e308 and `AngE` = -1e308, reading the document did not return. The reader now computes the pie's angles in constant time. The start angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When `AngE` is below `AngS` and that difference overflows to negative infinity, the sweep comes from the remainders of the two angles. The change and its two tests come from Riccardo Balloch's commit 79c5d673 in storytold/vectorcraft, which fixed the same loop in VectorCraft's copy of this reader. Here the tests read the document on a thread of their own and fail when reading takes more than 20 seconds. Co-authored-by: Riccardo Balloch * Contributors: add dotCooCoo (Robert Lee) --------- Co-authored-by: Riccardo Balloch Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 4 +++ crates/affinity/src/shapes.rs | 20 +++++++---- crates/affinity/src/tests.rs | 65 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index fadd139846..9a8bf4b4ce 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -136,3 +136,7 @@ sync_github_name = true [people.Hevarh1] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index 5e825fa850..f3bff62a33 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -115,13 +115,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> }))? } b"ShPi" => { - let start = num(r, shape, b"AngS").unwrap_or(0.0); - let mut end = num(r, shape, b"AngE").unwrap_or(TAU); - while end < start { - end += TAU; - } + let raw_start = num(r, shape, b"AngS").unwrap_or(0.0); + let raw_end = num(r, shape, b"AngE").unwrap_or(TAU); + // Normalize in constant time. Repeatedly adding TAU can hang on finite values + // large enough that floating-point addition no longer changes the endpoint. + let start = raw_start.rem_euclid(TAU); + let delta = raw_end - raw_start; + let sweep = if raw_end >= raw_start { + delta.min(TAU) + } else if delta.is_finite() { + delta.rem_euclid(TAU) + } else { + (raw_end.rem_euclid(TAU) - start).rem_euclid(TAU) + }; let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); - pie(cx, cy, rx, ry, start, end.min(start + TAU), inner) + pie(cx, cy, rx, ry, start, start + sweep, inner) } b"ShpT" => { let p = num(r, shape, b"Pos ").unwrap_or(0.5); diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index f70648b42d..7f1472b1ad 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -349,3 +349,68 @@ mod archive { } } } + +mod shapes { + use crate::container::Limits; + use crate::model::{Document, Kind, Path}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))], + ) + } + + /// Reads a document whose one page holds the object `top` from `definitions`, on a thread of + /// its own, and fails the test when reading takes more than 20 s. + fn read(definitions: Vec, top: u32) -> Document { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + 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, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))], + )]), + )], + ), + ), + ]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap() + } + + fn path(document: &Document) -> &Path { + match &document.spreads[0].nodes[0].kind { + Kind::Shape { path, .. } => path, + other => panic!("expected shape, got {other:?}"), + } + } + + #[test] + fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() { + let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100); + for subpath in &path(&document).subpaths { + assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite()); + assert!(subpath.segments.len() <= 20); + assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite())); + } + } + + #[test] + fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() { + let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100); + assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2))); + } +} From 7716d609e3b5e1dec95e1595672e18659e189014 Mon Sep 17 00:00:00 2001 From: B?! <12576795+callmeBalloch@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:20:44 +0200 Subject: [PATCH 032/223] Affinity: rectangles keep per-corner radii and straight corners (#1606) (#4055) Unlocked rectangle corners now each use their own radius and type (top left, top right, bottom right, bottom left) instead of all following the first one, and straight corners are cut across instead of rounded. Same fix as VectorCraft's copy of the reader (storytold/vectorcraft#1362). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/affinity/README.md | 4 +- crates/affinity/src/shapes.rs | 95 ++++++++++++++++++++++------------- crates/affinity/src/tests.rs | 47 ++++++++++++++++- 3 files changed, 108 insertions(+), 38 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index a88b85bd4d..4f0f9afe52 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -16,7 +16,7 @@ Affinity files. | Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) | -| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | +| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | | Compound shapes (`Comp`, add and subtract) | one even-odd shape | | A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom | | Vector masks (`AdCh` curves and shapes) | vector masks | @@ -34,7 +34,7 @@ Affinity files. Everything else is reported in the import warnings, one line per kind with a count, never dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, transparency gradients, fill opacity, bitmap fills, master pages, unrecognized -parametric shapes (imported as their bounding ellipses), corner types other than round, stars with +parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (one of each is used), strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index f3bff62a33..c7751da885 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty()); - let radius = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0), - _ => None, + let types = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + _ => Vec::new(), }; - if let (true, Some(f)) = (rounded, radius) { - if let Some(Value::Array(types)) = last(r, shape, b"CTyp") - && types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. })) - { - r.warn("rectangles with straight, concave or other corner types (imported with round corners)"); - } - let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) { - // An absolute radius is in document pixels, whatever the node's scale. - let [a, b2, c, d, ..] = world.0; - let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); - if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) } - } else { - (f * w.min(h), f * w.min(h)) - }; - rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0)) - } else { + let radii = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + _ => Vec::new(), + }; + // Corners follow the first one (top left) unless they were unlocked; then each has + // its own radius and type, clockwise from the top left. + let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false))); + let corners: [(f64, Option); 4] = std::array::from_fn(|i| { + let i = if locked { 0 } else { i }; + (radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten()) + }); + if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) { polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? + } else { + if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) { + r.warn("rectangles with concave or other corner types (imported with round corners)"); + } + let absolute = r.s.bool(shape, b"AbSz") == Some(true); + let [a, b2, c, d, ..] = world.0; + let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); + cornered_rect( + b, + corners.map(|(f, t)| { + let (crx, cry) = if !absolute { + (f * w.min(h), f * w.min(h)) + } else if sx > 0.0 && sy > 0.0 { + // An absolute radius is in document pixels, whatever the node's scale. + (f / sx, f / sy) + } else { + (0.0, 0.0) + }; + (crx, cry, t == Some(1)) + }), + ) } } b"ShpE" => ellipse(cx, cy, rx, ry), @@ -354,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { } } -fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath { - let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry); - SubPath { - start: pt(x0 + rx, y0), - segments: vec![ - [pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)], - [pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)], - [pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)], - [pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)], - [pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)], - [pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)], - [pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)], - [pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)], - ], - closed: true, +/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded, +/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along +/// one side shrink in proportion until they do. +fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath { + let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r }; + let [tl, tr, br, bl] = corners; + let (w, h) = (x1 - x0, y1 - y0); + let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)]; + let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)]; + let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r)); + // (where the corner leaves the previous side, its two handles, where it joins the next side) + let arcs = [ + [pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])], + [pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)], + [pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])], + [pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)], + ]; + let mut segments = Vec::with_capacity(8); + for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) { + segments.push([s, s, s]); + if s != e { + segments.push(if straight { [e, e, e] } else { [c1, c2, e] }); + } } + SubPath { start: pt(x0 + rx[0], y0), segments, closed: true } } /// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 7f1472b1ad..b28e30f3d6 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -352,7 +352,7 @@ mod archive { mod shapes { use crate::container::Limits; - use crate::model::{Document, Kind, Path}; + use crate::model::{Document, Kind, Path, Point, SubPath}; use crate::stream::Tag; use crate::synth::{self, F, Method, tag}; @@ -413,4 +413,49 @@ mod shapes { let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100); assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2))); } + + /// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline. + fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec) { + let node = + F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap(); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") }; + (path.subpaths[0].clone(), doc.warnings) + } + + /// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left). + fn corner_types(ids: [u16; 4]) -> F { + let mut raw = vec![0xaa]; + raw.extend(tag(b"CTyp").0.to_le_bytes()); + raw.extend(4u32.to_le_bytes()); + raw.extend(0u16.to_le_bytes()); + raw.extend(ids.iter().flat_map(|id| id.to_le_bytes())); + F::Raw(raw) + } + + #[test] + fn unlocked_rectangle_corners_keep_their_own_radius_and_type() { + let p = |x: f64, y: f64| Point { x, y }; + // Relative radii scale by 10. Round 5, round 1, straight 3, none. + let corners = |lock: Option| { + let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))]; + fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l)))); + rectangle(fields) + }; + + let (sub, warnings) = corners(Some(false)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(sub.start, p(5.0, 0.0)); + let ends: Vec = sub.segments.iter().map(|s| s[2]).collect(); + assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]); + assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line"); + assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve"); + + // Locked (the default): every corner follows the top left one. + let (sub, _) = corners(None); + assert_eq!(sub.segments.len(), 8); + assert_eq!(sub.segments[3][2], p(5.0, 20.0)); + } } From 96f4d68db713ee6fd86934114a0da86081e2bad1 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 01:23:56 +0100 Subject: [PATCH 033/223] feat(ui): spring-loaded tool letters (#2672) (#4238) In Photoshop, holding a tool's letter gives a temporary tool: releasing the key returns to the tool you had. A tool letter still switches tools when pressed; if the key then stays down for 300 ms or more, or the canvas is used while it's down, the switch was temporary and the release gives the previous tool back with its options. A quick tap stays a permanent switch. - Shift+letter (cycling the group) and Alt+letter never spring; nor do letters while typing in a field, editing type or under a dialog. - The current group's own letter cycles the group, as before. - A drag in progress when the key comes up finishes with the temporary tool; the previous tool returns once the button is up. - The newest held letter wins; the original tool returns when all are up. - A tool picked another way while a letter is down (the toolbar, a menu, automation) stands. - Key repeats of tool letters are dropped instead of cycling the group. - Z is one case: hold Z to zoom, add Alt to zoom out. The state is UiState::spring, readable over the control channel and never restored from settings. Tests that pressed tool letters without releasing them now release them, as a keyboard does. docs/ui-parity.md and docs/gaps.md mark #2672 done. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/artboard_tool.rs | 6 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/hold_keys/tests.rs | 10 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/marquee_tests.rs | 5 +- crates/ui-egui/src/shortcuts.rs | 18 +- crates/ui-egui/src/spring_tools.rs | 127 +++++++++++ crates/ui-egui/src/spring_tools/tests.rs | 269 +++++++++++++++++++++++ crates/ui-egui/src/state.rs | 5 + docs/gaps.md | 8 +- docs/ui-parity.md | 5 +- 11 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/spring_tools.rs create mode 100644 crates/ui-egui/src/spring_tools/tests.rs diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 79a7cd3b78..6b996c3f14 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -417,7 +417,11 @@ mod tests { let press = |app: &mut PhotocraftApp, modifiers| { let key = egui::Key::V; let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b053af1a41..38d1e33f69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2903,6 +2903,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Under an open dialog the canvas widget is inert, but the image still pans and zooms. let under_dialog = !app.ui.dialogs.is_empty(); let free_hover = under_dialog && crate::dialogs::free_pointer_over(&ctx, rect).is_some(); + // A press on the canvas while a tool letter is held makes that tool temporary (#2672). + if !under_dialog && ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { + 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); diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index d558a5a7b7..cfe89d4642 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -72,9 +72,15 @@ fn key(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, m: Modifi h.run_steps(1); } +/// A quick press and release in one frame (a tool letter held across frames springs, #2672). fn tap(h: &mut Harness<'static, PhotocraftApp>, k: Key, m: Modifiers) { - key(h, k, true, m); - key(h, k, false, m); + let m = platform(m); + h.input_mut().events.extend([ + Event::ModifiersChanged(m), + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + h.run_steps(1); h.event(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05c49b60d1..2bfa9e96fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -175,6 +175,7 @@ pub mod slice_ui; pub mod smart_ui; pub mod snap_ui; pub(crate) mod solid_fill_ui; +pub mod spring_tools; pub mod state; pub mod stroke_constraint; pub mod stroke_trail; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 241f5aa048..cf4832904a 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -792,7 +792,10 @@ fn m_cycles_only_the_rectangular_and_elliptical_marquee() { let mut seen = Vec::new(); for _ in 0..4 { let raw = egui::RawInput { - events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }], + events: vec![ + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::SHIFT }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5b549d6679..6e592385a1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -297,6 +297,8 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu } pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + // A held tool letter released: a temporary tool gives the previous one back (#2672). + crate::spring_tools::tick(app, ctx); // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings // it back later (#2608). app.ui.remember_group_tool(); @@ -573,17 +575,24 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the // group's current tool. From another group, the key picks the group's last-used tool. + // A held tool letter is a spring-loaded tool (#2672), not a burst of presses: its key + // repeats are dropped rather than cycling the group. + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key)))); let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { - let Some(k) = Key::from_name(&t.key().to_string()) else { continue }; + let Some(k) = crate::spring_tools::key_of(t.key()) else { continue }; let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { + let before = app.ui.tool; let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect(); app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], None => app.ui.group_tool(t.key()).unwrap_or(group[0]), }; + // `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); return; } } @@ -604,7 +613,11 @@ mod tests { let ctx = egui::Context::default(); let press = |app: &mut PhotocraftApp, key, modifiers| { let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); @@ -644,6 +657,7 @@ mod tests { events: vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key: Key::T, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: Key::T, physical_key: None, pressed: false, repeat: false, modifiers }, ], ..Default::default() }; diff --git a/crates/ui-egui/src/spring_tools.rs b/crates/ui-egui/src/spring_tools.rs new file mode 100644 index 0000000000..5f51136cdb --- /dev/null +++ b/crates/ui-egui/src/spring_tools.rs @@ -0,0 +1,127 @@ +//! Spring-loaded tool keys (#2672): hold a tool's letter for a temporary tool. +//! +//! A tool letter switches tools when it is pressed, as before (`shortcuts::handle`). If the key is +//! then held for [`SPRING_HOLD_SECS`] or longer, or the canvas is used while it is down, the switch +//! was only temporary: releasing the key gives back the tool in effect before the press, with its +//! options. A quick tap with no canvas use stays a permanent switch. +//! +//! - ⇧+letter (cycling the group) and ⌥+letter never spring; nor do letters while typing in a +//! field, editing type or under a modal dialog, which never reach the tool keys. +//! - Pressing the current group's own letter cycles the group, as a tap does, and doesn't spring. +//! - A canvas drag still in progress when the key comes up finishes with the temporary tool; the +//! previous tool returns once the button is up (as `hold_keys` latches Space). +//! - Another letter pressed while one is held wins (newest first); releasing every held letter +//! returns the original tool. Holding two letters at once counts as a hold. +//! - Key repeats are not presses: `shortcuts::handle` drops them for tool letters. +//! +//! Z is one case: hold Z to zoom, add ⌥ to zoom out, as with the Zoom tool itself (and as +//! `hold_keys` does for ⌥ with a held Zoom In). +//! +//! The state is [`crate::state::UiState::spring`], readable over the control channel. + +use egui::Key; +use serde::{Deserialize, Serialize}; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// How long (seconds) a tool letter must stay down for its tool to be temporary. +pub const SPRING_HOLD_SECS: f64 = 0.3; + +/// A tool switch made by a letter that is still down (or whose drag is still finishing). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spring { + /// The tool to give back. + pub previous: Tool, + /// The tool the held letter picked. If the tool changes any other way while it's held (the + /// toolbar, a menu, automation), that choice stands and nothing springs back. + pub tool: Tool, + /// The tool letters held, oldest first. + pub keys: Vec, + /// `egui` input time of the first press. + pub since: f64, + /// The canvas was pressed (or a second letter pressed) while held: the switch is temporary + /// however short the hold. + pub used: bool, + /// Every letter is up and the switch is temporary, but a drag is still in progress. + pub released: bool, +} + +/// The egui key of a tool letter (`None` for the tools without one). +pub fn key_of(letter: char) -> Option { + if letter == '\0' { + return None; + } + Key::from_name(&letter.to_string()) +} + +/// Is `k` some tool's single-key shortcut? +pub fn is_tool_key(k: Key) -> bool { + Tool::ALL.iter().any(|t| key_of(t.key()) == Some(k)) +} + +/// A tool letter switched the tool from `before` (the switch is already made). `plain` is false +/// when ⇧ or ⌥ was held: those switches are permanent. `now` is the input time. +pub fn pressed(app: &mut PhotocraftApp, letter: char, before: Tool, plain: bool, now: f64) { + let active = app.ui.spring.as_ref().is_some_and(|s| !s.released); + if !plain { + app.ui.spring = None; + return; + } + if active { + if let Some(s) = app.ui.spring.as_mut() { + s.keys.retain(|k| *k != letter); + s.keys.push(letter); + s.used = true; + s.tool = app.ui.tool; + } + return; + } + // The current group's own key cycles it (or keeps it), as a tap always has. + let tool = app.ui.tool; + app.ui.spring = (before.key() != letter).then(|| Spring { previous: before, tool, keys: vec![letter], since: now, used: false, released: false }); +} + +/// The canvas was pressed: a held letter's tool becomes temporary. +pub fn note_canvas_press(app: &mut PhotocraftApp) { + if let Some(s) = app.ui.spring.as_mut().filter(|s| !s.released) { + s.used = true; + } +} + +/// Once a frame, before the key handling: notice released letters and give the previous tool +/// back when the switch was temporary and no drag is in progress. +pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(mut s) = app.ui.spring.take() else { return }; + // Type editing begun with the temporary Type tool keeps it: the edit owns the tool now. So + // does a tool picked another way while the letter is down. + if app.ui.text_edit.is_some() || app.ui.tool != s.tool { + return; + } + let (now, button) = ctx.input(|i| (i.time, i.pointer.any_down())); + let dragging = app.drag.is_some() || button; + if !s.released { + if app.drag.is_some() { + s.used = true; + } + s.keys.retain(|k| key_of(*k).is_some_and(|k| ctx.input(|i| i.key_down(k)))); + if !s.keys.is_empty() { + app.ui.spring = Some(s); + return; + } + if !s.used && now - s.since < SPRING_HOLD_SECS { + // A tap: the switch stands. + return; + } + s.released = true; + } + if dragging { + app.ui.spring = Some(s); + ctx.request_repaint(); + return; + } + app.ui.tool = s.previous; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/spring_tools/tests.rs b/crates/ui-egui/src/spring_tools/tests.rs new file mode 100644 index 0000000000..eef327c096 --- /dev/null +++ b/crates/ui-egui/src/spring_tools/tests.rs @@ -0,0 +1,269 @@ +//! #2672 through the real key handling and canvas: a tapped tool letter switches for good, a held +//! one (or one used on the canvas) springs back on release. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use super::*; +use crate::canvas::ViewXform; + +/// Frames are 1/60 s apart, so a hold is a count of frames. +const DT: f32 = 1.0 / 60.0; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +/// One key event with its modifiers, run as a frame. +fn key_event(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, repeat: bool, m: Modifiers) { + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key: k, physical_key: None, pressed, repeat, modifiers: m }); + h.run_steps(1); +} + +fn down(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, true, false, Modifiers::NONE); +} + +fn up(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, false, false, Modifiers::NONE); + h.run_steps(2); +} + +/// Wait `secs` of input time. +fn wait(h: &mut Harness<'static, PhotocraftApp>, secs: f64) { + h.run_steps((secs / f64::from(DT)).ceil() as usize); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, m: Modifiers) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: m }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + move_to(h, x, y); + let p = screen(h, x, y); + button(h, p, true, m); + button(h, p, false, m); +} + +#[test] +fn a_tap_switches_tools_for_good() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert!(h.state().ui.spring.is_some(), "the press is pending: tap or hold"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn a_held_letter_gives_the_previous_tool_back() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + assert_eq!(h.state().ui.tool, Tool::Eraser, "the temporary tool is the tool shown meanwhile"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn hold_z_click_zooms_then_a_quick_release_restores() { + let mut h = harness(Tool::Brush); + let z0 = h.state().ui.views[0].zoom; + down(&mut h, Key::Z); + click(&mut h, 200.0, 150.0, Modifiers::NONE); + up(&mut h, Key::Z); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + assert_eq!(h.state().ui.tool, Tool::Brush, "a canvas click makes even a short hold temporary"); + assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); + // ⌥ with a held Z zooms out, as ⌥ does with the Zoom tool. + down(&mut h, Key::Z); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + up(&mut h, Key::Z); + assert!(h.state().ui.views[0].zoom < z1); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn shift_letter_cycles_and_never_springs() { + let mut h = harness(Tool::Brush); + h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); + key_event(&mut h, Key::B, true, false, Modifiers::SHIFT); + assert_eq!(h.state().ui.tool, Tool::Pencil); + assert_eq!(h.state().ui.spring, None); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::B, false, false, Modifiers::SHIFT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Pencil); + + // Without the preference ⇧E is still a permanent switch, however long it is held. + let mut h = harness(Tool::Brush); + key_event(&mut h, Key::E, true, false, Modifiers::SHIFT); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::E, false, false, Modifiers::SHIFT); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn key_repeats_are_not_presses() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + for _ in 0..8 { + key_event(&mut h, Key::L, true, true, Modifiers::NONE); + wait(&mut h, 0.05); + } + assert_eq!(h.state().ui.tool, Tool::Lasso, "repeats don't cycle the group"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn a_drag_keeps_the_temporary_tool_until_the_button_is_up() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + move_to(&mut h, 50.0, 50.0); + let p = screen(&h, 50.0, 50.0); + button(&mut h, p, true, Modifiers::NONE); + move_to(&mut h, 100.0, 90.0); + up(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee, "the drag finishes with the temporary tool"); + move_to(&mut h, 150.0, 110.0); + let p = screen(&h, 150.0, 110.0); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert!(sel.is_some_and(|r| r.width() > 50 && r.height() > 30), "{sel:?}"); +} + +#[test] +fn the_newest_letter_wins_and_release_restores_the_original() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso, "L is still held"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn letters_type_while_editing_text() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "a", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 1, + anchor: 1, + session: "spring-test".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); + h.event(Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("b".into())); + h.run_steps(1); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + up(&mut h, Key::B); + assert_eq!(h.state().ui.tool, Tool::Type); + assert_eq!(h.state().ui.spring, None); + let st = h.state().session.active().unwrap(); + match &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => assert_eq!(t.text, "ab"), + _ => panic!("not text"), + } +} + +/// A tool picked another way while a letter is held (here, as the toolbar would) stands: the release +/// doesn't take it back. +#[test] +fn a_tool_picked_while_a_letter_is_held_stands() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + h.state_mut().ui.tool = Tool::Lasso; + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn plain_functions_follow_the_rules() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Eraser; + // The current group's own key cycles; no spring. + pressed(&mut app, 'E', Tool::Eraser, true, 0.0); + assert_eq!(app.ui.spring, None); + // A ⇧ switch clears any pending spring. + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + assert!(app.ui.spring.is_some()); + pressed(&mut app, 'L', Tool::Brush, false, 0.1); + assert_eq!(app.ui.spring, None); + // A canvas press marks a pending spring used; nothing to mark without one. + note_canvas_press(&mut app); + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + note_canvas_press(&mut app); + assert!(app.ui.spring.as_ref().is_some_and(|s| s.used && s.previous == Tool::Eraser)); + assert_eq!(key_of('\0'), None); + assert!(is_tool_key(Key::Z) && !is_tool_key(Key::F1)); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 02b4f005db..50f4b8b680 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1033,6 +1033,10 @@ pub struct UiState { /// one per group: the group's key brings it back, as in Photoshop (#2608). #[serde(default)] pub group_tools: Vec, + /// A tool letter still held (spring-loaded tool, #2672): the tool to give back on release. + /// Exposed to automation, never restored from settings. + #[serde(default, skip_deserializing)] + pub spring: Option, /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, @@ -1244,6 +1248,7 @@ impl Default for UiState { Self { tool: Tool::Brush, group_tools: Vec::new(), + spring: None, recent_files: Vec::new(), text_edit: None, type_transform: None, diff --git a/docs/gaps.md b/docs/gaps.md index 870613ae30..5a50685368 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-12 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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 @@ -156,7 +156,7 @@ what an agent can't finish alone. ### G5. Transform, handles and modifiers - **Missing:** H/V skew fields and the full interpolation list; blend mode/opacity edits during a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); - hold-a-key spring-loaded tools (#2672); wheel and pinch behaviour (#2485, #2463, #1989); + wheel and pinch behaviour (#2485, #2463, #1989); toolbar press-and-hold flyouts (#2301). - **Done since the re-measure (2026-10-10 to 12):** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the options bar (#2442); transform keeps the layer's @@ -164,7 +164,8 @@ what an agent can't finish alone. locked layers refused before the drag (#2585, #2609); ⌘-drag temporary Move with box and snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool - to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625). + to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded + tools (#2672). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -239,6 +240,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 76b59de719..70b503d0c8 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-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters) · **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, @@ -161,7 +161,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | -| Hold any tool letter for a temporary tool | missing | #2672 | +| Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | | Animated zoom | missing | unread pref; #566 | @@ -219,3 +219,4 @@ Paragraph; the rest open as in-app floating windows. | 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 | +| 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | From 0fbba911bad32654b0075796a21f1ba05c40af93 Mon Sep 17 00:00:00 2001 From: Mohammed Date: Tue, 13 Oct 2026 03:29:06 +0300 Subject: [PATCH 034/223] Quick Selection: cut window sizes in parallel, cancel the larger ones once one is kept (#4062) Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for 0.2-0.5 s: the growing-window loop cut a window, found the selection touching its side, doubled the margin and started over, and on real photos every stroke needed 2-4 sizes. The window sizes are independent, so the next 4 are now cut at once (rayon) and the first in growing order that stays clear of its sides is kept: the same result as one after another. Once one is kept, larger windows of the batch stop through the cancel hook grid_cut_impl / maxflow_with already have, now also polled before the graph is built, before the max-flow and between the coarse and fine levels of cut_levels. wasm32 keeps the sequential loop. 36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s -> 13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows). Co-authored-by: Mohammed <24667144+syncroot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/mod.rs | 6 ++ crates/algo/src/segment/quick.rs | 138 ++++++++++++++++++++++++++----- crates/algo/src/segment/tests.rs | 39 +++++++++ 3 files changed, 164 insertions(+), 19 deletions(-) diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index dba4ebfce5..90cc2f502c 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -288,6 +288,9 @@ fn grid_cut_impl( if count == 0 { return Some(out); } + if cancel() { + return None; + } let mut g = Graph::with_capacity(count as usize, count as usize * 4); for i in 0..n { if idx[i] != u32::MAX { @@ -330,6 +333,9 @@ fn grid_cut_impl( } } } + if cancel() { + return None; + } if OPTIMIZED { g.maxflow_with(cancel)?; } else { diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 859575b2cc..8306b6e662 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3 } pub(super) fn quick_select_using(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option { + quick_select_batched::(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region) +} + +/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also +/// returns how many window sizes, smallest first, it took to find the kept one. +pub(super) fn quick_select_batched( + sampler: &dyn Sampler, + canvas: Rect, + points: &[(f32, f32)], + size: f32, + t: &Tuning, + batch: usize, +) -> Option<(Region, usize)> { if points.is_empty() || canvas.is_empty() { return None; } @@ -107,8 +120,15 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32; let rw = r / step as f32; // Work in a window around the stroke; grow it while the selection runs into its border. - let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; - loop { + // The window's sides are free like the canvas' edges, so a selection that stays clear of + // them is kept; one that reaches a side inside the canvas is recomputed in a larger window. + // Index of the smallest window kept so far in this batch: larger ones stop cutting. + let kept = std::sync::atomic::AtomicUsize::new(usize::MAX); + let attempt = |index: usize, margin: i32| -> Attempt { + let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index; + if cancel() { + return Attempt::Cancelled; + } let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step; let grown = bbox.inflate(margin).intersect(&canvas); let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1); @@ -118,13 +138,13 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect(); let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh); if !seeds.iter().any(|v| *v) { - return None; + return Attempt::NoSeeds; + } + if cancel() { + return Attempt::Cancelled; } - // The window's sides are free like the canvas' edges, so a selection that stays clear of - // them is the best over the whole canvas too (outside the window it could only add cost). - // One that reaches a side inside the canvas is recomputed in a larger window. let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1]; - let low = select_working_using::(&img, &seeds, rw, t); + let Some(low) = select_working_using::(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled }; let touches = |side: usize| match side { 0 => (0..lh).any(|y| low[y * lw]), 1 => (0..lw).any(|x| low[x]), @@ -132,21 +152,87 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c _ => (0..lw).any(|x| low[(lh - 1) * lw + x]), }; if (0..4).any(|k| inner[k] && touches(k)) { - margin = margin.saturating_mul(2); - continue; + return Attempt::Touches; } - let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); - return super::trim_region(Region { bbox: window, mask }); + kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed); + Attempt::Done { window, low, lw, lh } + }; + let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; + let mut sizes = 0; + loop { + // The attempts don't depend on each other, so a few window sizes are cut at once and the + // first in growing order that stays clear of its sides is kept: the same result as trying + // them one after another, without waiting for the smaller ones to fail first. Once one is + // kept, the larger ones still cutting stop, so a stroke settled by a small window costs + // about what that window alone costs. + let batch = margins_to_try(margin, bbox, canvas, batch); + kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed); + #[cfg(not(target_arch = "wasm32"))] + let results: Vec = { + use rayon::prelude::*; + batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect(); + for result in results { + sizes += 1; + match result { + Attempt::NoSeeds => return None, + // Only a window larger than one already kept is cancelled, and that one comes + // first in this order, so a cancelled attempt is never reached. + Attempt::Touches | Attempt::Cancelled => continue, + Attempt::Done { window, low, lw, lh } => { + let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); + return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes)); + } + } + } + let &last = batch.last()?; + margin = last.saturating_mul(2); } } +/// One window size's outcome in [`quick_select_using`]. +enum Attempt { + /// The stroke misses the window. + NoSeeds, + /// The selection reaches a side of the window inside the canvas. + Touches, + /// A smaller window of the same batch was kept first. + Cancelled, + Done { + window: Rect, + low: Vec, + lw: usize, + lh: usize, + }, +} + +/// Window sizes cut together: most strokes settle within this many doublings. +const SPECULATE: usize = 4; + +/// The next `count` margins from `margin`, doubling, stopping at the first one whose window +/// already covers the whole canvas (a larger one would be the same window). +pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.max(1)); + let mut m = margin; + for _ in 0..count.max(1) { + out.push(m); + if bbox.inflate(m).intersect(&canvas) == canvas { + break; + } + m = m.saturating_mul(2); + } + out +} + /// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are /// selected; returns the selection. pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { - select_working_using::(img, seeds, radius, t) + select_working_using::(img, seeds, radius, t, &|| false).unwrap_or_default() } -fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { +fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option> { let (w, h) = (img.w, img.h); let d = crate::selection::edt(seeds, w, h); let grow = (t.grow + t.grow_frac * radius).max(0.0); @@ -154,7 +240,10 @@ fn select_working_using(img: &RgbImage, seeds: &[bool], r let fixed: Vec = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect(); let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k); let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k)); - cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024)) + if cancel() { + return None; + } + cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel) } /// Band half-width (pixels) re-cut at each finer level. @@ -164,11 +253,19 @@ const BAND: f32 = 3.0; /// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two /// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds /// stay foreground. -fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec { +fn cut_levels( + img: &RgbImage, + fixed: &[u8], + cost_fg: &[f32], + weight: &dyn Fn(f32) -> f32, + scale: f32, + direct_px: usize, + cancel: &dyn Fn() -> bool, +) -> Option> { let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_weighted::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2)); + return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -183,7 +280,10 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } } } - let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px); + let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?; + if cancel() { + return None; + } let proj: Vec = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect(); // Band: within BAND of the projected boundary. let edge: Vec = (0..w * h) @@ -194,7 +294,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 }) .collect(); if !edge.iter().any(|e| *e) { - return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect(); + return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect()); } let near = crate::selection::edt(&edge, w, h); let banded: Vec = (0..w * h) @@ -210,7 +310,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } }) .collect(); - super::grid_cut_weighted::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2)) + super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index c3a37dc6dd..4f741eb227 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() { } } +#[test] +fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() { + // Soft gradients and faint blobs let a selection spread, so most strokes need more than one + // window size; cutting several at once must keep exactly what one-after-another keeps. + let (w, h) = (1200usize, 900usize); + let img = RgbImage::from_fn(w, h, |x, y| { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06; + [0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob] + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + let t = quick::Tuning::default(); + let strokes: [(&[(f32, f32)], f32); 4] = [ + (&[(300.0, 300.0)], 30.0), + (&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0), + (&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0), + (&[(900.0, 200.0)], 120.0), + ]; + let mut most = 0; + for (pts, size) in strokes { + let one = quick::quick_select_batched::(&s, canvas, pts, size, &t, 1); + let many = quick::quick_select_batched::(&s, canvas, pts, size, &t, 4); + assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}"); + most = most.max(one.map_or(0, |(_, n)| n)); + } + assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing"); +} + +#[test] +fn quick_select_window_sizes_stop_at_the_canvas() { + let canvas = Rect::new(0, 0, 1000, 800); + let bbox = Rect::new(400, 300, 460, 360); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]); + // 768 already covers the canvas: no larger (identical) window is cut. + assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]); +} + #[test] fn quick_select_grows_without_leaking() { let img = two_regions(140, 100); From b1b3dff8f12e8b1862ba51e8bdf89582a8c8208b Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:02 +0200 Subject: [PATCH 035/223] PSD: a zero-length annotation entry no longer hangs the import (#4149) (#4117) Root cause: parse_entries sets the cursor to start + len after each Anno entry. An entry length below 8 (its own u32 plus the type) left the cursor on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times, pushing an entry each time until allocation failed and the process aborted (not a panic, so the import guard could not catch it). Fix: reject an entry length below 8, so the block is reported unreadable and kept verbatim (the #944 behaviour); every entry now advances at least 8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries plus the hostile 16-byte block. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/io/src/annotations_map.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 28ab181c83..27b6c04477 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -133,6 +133,11 @@ fn parse_entries(data: &[u8]) -> Option> { for _ in 0..count { let start = r.p; let len = r.u32()? as usize; + // The length counts itself and the type: anything shorter would put the cursor back on + // this entry, and a hostile count would then loop until memory runs out. + if len < 8 { + return None; + } let end = start.checked_add(len)?; let raw = data.get(start..end)?; let kind = r.take(4)?; @@ -422,6 +427,22 @@ mod tests { assert!(!anno_unreadable(&d)); } + /// An entry length below 8 (its own `u32` plus the type) put the cursor back on the same + /// entry, so a count of 0xFFFFFFFF looped ~4.3e9 times and ran out of memory. + #[test] + fn short_entry_length_is_unreadable_not_a_loop() { + for len in [0u8, 4, 7] { + for kind in [b"sndA", b"txtA"] { + let mut b = vec![0, 2, 0, 1, 0, 0, 0, 3, 0, 0, 0, len]; + b.extend_from_slice(kind); + b.extend_from_slice(&[0; 64]); + assert!(parse_entries(&b).is_none(), "len {len} {kind:?}"); + } + } + let hostile = [0, 2, 0, 1, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0, b's', b'n', b'd', b'A']; + assert!(parse_anno(&hostile).is_none()); + } + #[test] fn scale_resource_roundtrip_and_keep_rules() { let s = MeasurementScale { pixel_length: 150.0, logical_length: 2.0, units: "mm".into() }; From b87180e14e454f55f1baa7a2cd6effd5abccc693 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:07 +0200 Subject: [PATCH 036/223] Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118) Root cause: SwatchColor::from_param checked that each value was finite as an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the prefs file wrote it as null, and on the next launch load_presets_json failed to parse the whole presets blob, so every user gradient, style, shape, tool/document preset and swatch fell back to the defaults. Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for every colour model. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/presets/swatches.rs | 11 ++++++++++- crates/engine/src/swatch_cmds/tests.rs | 27 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index f46aad32da..eec8c2d0d8 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -166,7 +166,14 @@ impl SwatchColor { .and_then(Value::as_array) .ok_or("a colour needs `values`")? .iter() - .map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f as f32).ok_or("colour values must be finite numbers")) + // Beyond f32 range a value turns into inf as f32 (an HSB hue then wraps to NaN, which + // the prefs file can't store), so reject it here. + .map(|x| { + x.as_f64() + .filter(|f| f.is_finite() && f.abs() <= f64::from(f32::MAX)) + .map(|f| f as f32) + .ok_or("colour values must be finite numbers within f32 range") + }) .collect::>()?; let need = |n: usize| if vals.len() == n { Ok(()) } else { Err(format!("a {model} colour has {n} values, got {}", vals.len())) }; let pct = |v: f32| (v / 100.0).clamp(0.0, 1.0); @@ -403,6 +410,8 @@ mod tests { json!({"model": "rgb", "values": ["a", 2, 3]}), json!({"model": "xyz", "values": [1, 2, 3]}), json!({"model": "gray", "values": []}), + json!({"model": "hsb", "values": [1e39, 100, 100]}), + json!({"model": "rgb", "values": [0, -1e39, 0]}), ] { assert!(SwatchColor::from_param(&bad).is_err(), "{bad}"); } diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 4a92441b71..cb121a1832 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -289,3 +289,30 @@ fn library_limits() { assert_eq!(total(&g), MAX_SWATCHES); assert_eq!(g[0].name.chars().count(), MAX_NAME); } + +/// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN +/// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset. +#[test] +fn values_beyond_f32_range_are_rejected_so_presets_survive_a_restart() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "Keep me", "color": {"model": "lab", "values": [40, 12.5, -7.25]}})).unwrap(); + let before = s.presets.swatches.clone(); + for (model, values) in [ + ("hsb", json!([1e39, 100, 100])), + ("hsb", json!([-1e39, 100, 100])), + ("rgb", json!([1e39, 0, 0])), + ("cmyk", json!([0, 0, 0, -1e300])), + ("lab", json!([50, 1e39, 0])), + ("gray", json!([1e39])), + ] { + let r = s.execute("swatches.add", json!({"name": "Bad", "color": {"model": model, "values": values}})); + assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{model} {values}: {r:?}"); + } + assert_eq!(s.presets.swatches, before, "a rejected colour leaves the library unchanged"); + // The largest f32 hue is still accepted and stays finite. + s.execute("swatches.add", json!({"name": "Big hue", "color": {"model": "hsb", "values": [f64::from(f32::MAX), 100, 100]}})).unwrap(); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.presets.swatches, s.presets.swatches, "user swatches survive a save and reload"); + assert!(names(&t).contains(&"Keep me".to_string())); +} From b0451df33b93b8e082263cc5bc4322a9e1b036f8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:11 +0200 Subject: [PATCH 037/223] Compose: the effect cache follows Transparency Shapes Layer and Blend If (#4119) Root cause: layer_identity, the effect-map cache key, hashed pixels, masks, fill opacity and effects but not layer.advanced or layer.blend_if. Turning Transparency Shapes Layer off (which makes the whole layer the effect shape) or changing a styled group child's Blend If (which changes the composite the group's shape comes from) left the key and region unchanged, so stale maps were reused: a stroke kept rimming a hole until the cache was purged. Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into group children). Regression tests compare a warm render after each edit with a cold render after purge_effect_cache. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 7 ++++++- crates/compose/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index eafdb44616..ae9e53e39c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1387,7 +1387,8 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O // --------------------------------------------------------------------------------------------- // Layer-effect map cache -/// Order-independent identity of a layer's pixels, masks, effects and (for groups) children. +/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If +/// and (for groups) children. /// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the /// layer so those tiles (and their addresses) stay alive while the entry exists. fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { @@ -1445,6 +1446,10 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash format!("{vm:?}").hash(h); } format!("{:?}", layer.effects).hash(h); + // Transparency Shapes Layer picks the shape (content or the whole layer); a group child's + // Blend If and knockout change the composite a styled group's shape comes from. + layer.advanced.hash(h); + layer.blend_if.hash(h); h.write_u8(0xfe); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index aca7a10c92..e8bf005e59 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2487,3 +2487,44 @@ fn lab_documents_gradient_map_follows_lightness() { adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb); assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } + +// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape +// the maps are built from (a layer's own, or a styled group's children composite) without +// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer +// had. Each case renders with the cache warm, edits, renders again and compares with a cold +// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.) +#[test] +fn effect_cache_follows_transparency_shapes_layer() { + let mut d = doc_white(32, 32); + let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]); + l.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + assert!(l.advanced.transparency_shapes); + d.layers.push(l); + let on = render(&d, d.bounds()); + d.layers[1].advanced.transparency_shapes = false; + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(on.px != cold.px, "the stroke should stop rimming the hole"); + assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off"); +} + +#[test] +fn effect_cache_follows_a_group_childs_blend_if() { + let mut d = doc_white(32, 32); + let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]); + child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]); + let mut g = Layer::group("g", vec![child]); + g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })]; + d.layers.push(g); + let before = render(&d, d.bounds()); + let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") }; + // Gray › This Layer: black point at 128 hides the black half. + grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]); + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(before.px != cold.px, "Blend If should change the group's shadow"); + assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); +} From 426747d7d2c974a7b5620dea3e876ae240de818b Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:15 +0200 Subject: [PATCH 038/223] Auto-Select: honour layer mask density when picking layers (#4153) (#4121) layer.pickAt (Auto-Select, the canvas right-click layer list and the Move tool's rollover highlight) read the raw layer-mask pixel, while the compositor uses LayerMask::value, which applies density (1 - d*(1 - v)). A hide-all mask at density 0 shows the layer in full, yet the click went through to the layer below. Read the mask through LayerMask::value, as the compositor does (it also returns 1 for a disabled mask). Regression test covers the click and the list form. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/pick_cmds.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index c8583305da..d3bee1c6ac 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -61,11 +61,8 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec None => 0.0, }, }; - let mask = l.mask.as_ref().filter(|m| m.enabled).map_or(1.0, |m| { - let mut v = [0.0f32]; - m.surface.read_pixel(x, y, &mut v); - v[0] - }); + // As the compositor sees it: density applied, all ones when the mask is disabled. + let mask = l.mask.as_ref().map_or(1.0, |m| m.value(x, y)); if alpha * mask > 0.0 { out.push(l.id); } @@ -445,4 +442,24 @@ mod tests { let mut empty = Session::new(); assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err()); } + + /// A hide-all mask at density 0 hides nothing (the compositor applies density through + /// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through. + #[test] + fn mask_density_is_honoured_when_picking() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let red = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + // Full density: the mask hides the layer, so the click reaches the Background. + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); + s.execute("layer.layerMask.edit", json!({"density": 0})).unwrap(); + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], red.0); + let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); + let ids: Vec<_> = list["layers"].as_array().unwrap().iter().map(|l| l["layer"].clone()).collect(); + assert_eq!(ids, vec![json!(red.0), json!(bg.0)]); + } } From 1014c37eaa4381b94037be95b4b3026e07e7ed3a Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:20 +0200 Subject: [PATCH 039/223] Find and Replace Text: backward Change/Find moves past the replaced text (#4154) (#4122) After Replace Text the find cursor collapsed to the end of the replacement. A backward search takes the last match ending at or before the cursor, so when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive) it found the replaced text again and never reached earlier matches. Collapse the cursor at the start of the replacement when searching backward (the end when forward), mirroring how forward search skips it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/edit_menu_cmds.rs | 5 ++++- crates/engine/src/edit_menu_cmds/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 103a5edc6e..4933ed93fc 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -903,7 +903,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let end = a + replace.len(); - s.edit_state.find_cursor = Some((id, end, end)); + // Collapse the cursor on the side the search moves away from, so the next search + // skips the replaced text even when the replacement still matches. + let c = if forward { end } else { a }; + s.edit_state.find_cursor = Some((id, c, c)); changed = Some((id, a, end)); if action == "change" { return Ok(json!({"changed": {"layer": id.0, "start": a, "end": end}})); diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 7b58891a82..0a1bab4633 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -468,3 +468,26 @@ fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { let r = s.execute("edit.findAndReplaceText", json!({"find": "abc", "allLayers": false, "action": "find"})).unwrap(); assert_eq!((r["found"]["layer"].as_u64(), r["found"]["text"].as_str()), (Some(id), Some("abc"))); } + +#[test] +fn backward_change_find_moves_past_the_replaced_text() { + // A backward Change/Find whose replacement still matches ("cat" -> "Cat", case-insensitive) + // must move on to the previous match, not find the replaced text again. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let p = |action: &str, forward: bool| json!({"find": "cat", "replace": "Cat", "action": action, "forward": forward, "allLayers": false}); + let f = s.execute("edit.findAndReplaceText", p("find", false)).unwrap(); + assert_eq!((f["found"]["start"].as_u64(), f["found"]["end"].as_u64()), (Some(8), Some(11)), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", false)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + + // Forward is unchanged: replace the first match, then find the next one. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let f = s.execute("edit.findAndReplaceText", p("find", true)).unwrap(); + assert_eq!(f["found"]["start"].as_u64(), Some(0), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", true)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); +} From 991ebe16d5ead1475d978ac7c73b6e5e2bd55942 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:24 +0200 Subject: [PATCH 040/223] Liquify, Puppet Warp and Perspective Warp: refuse locked smart objects (#4155) (#4123) Root cause: run_on_layer in distort_cmds.rs added the smart filter and returned for a smart object before it called check_locks, so a smart object with Lock All or Lock Pixels (its own or a parent group's) took the distortion and a history step. Fix: check the pixel and full locks (not position) before recording the smart filter, as regular filters do, and use the filters' error wording. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/distort_cmds.rs | 5 ++- crates/engine/src/distort_cmds/tests.rs | 59 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index c164eaf679..55556c53d8 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -216,7 +216,7 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> { fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> { let locks = l.locks.union(group); if locks.all || locks.pixels || (position && locks.position) { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); } Ok(()) } @@ -244,6 +244,9 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let LayerContent::Smart(_) = l.content { + // As filters do: a pixel or full lock refuses the smart filter (the position lock + // doesn't, since the smart object itself stays put). + check_locks(l, group, false)?; let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index d0ea88ed05..50a8a49e62 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -352,3 +352,62 @@ fn liquify_with_a_far_off_stroke_point_returns() { assert_eq!(active_surface(&s).read_region(Rect::new(0, 0, 80, 60)), before.read_region(Rect::new(0, 0, 80, 60)), "{depth}: off-canvas only"); } } + +/// The three distortions on the active smart object, each with a visible change. +fn smart_distortions() -> [(&'static str, Value); 3] { + let q = [[10.0, 10.0], [60.0, 10.0], [60.0, 40.0], [10.0, 40.0]]; + [ + (LIQUIFY, json!({"strokes": [{"tool": "twirlCw", "size": 40, "points": [[35, 25], [35, 25], [35, 25]]}]})), + (PUPPET_SMART, json!({"pins": [{"src": [30, 25], "dst": [34, 25]}], "mode": "rigid"})), + (PERSPECTIVE_SMART, json!({"planes": [{"src": q, "dst": [[12.0, 10.0], [58.0, 10.0], [60.0, 40.0], [10.0, 40.0]]}]})), + ] +} + +#[test] +fn locked_smart_objects_refuse_distortions() { + // As filters do: a pixel or full lock (the layer's own or its group's) refuses the smart + // filter, leaving the layer and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the smart object ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().clone(); + let r = s.execute(cmd, p); + assert!(r.is_err(), "{cmd} edited a smart object locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(layer).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.doc.layer(layer).unwrap().surface(), before.surface(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{cmd} {lock} group: {on_group}: no history step"); + } + } + } +} + +#[test] +fn unlocked_or_position_locked_smart_objects_take_distortions() { + // Filters don't check the position lock, so neither do the smart-filter distortions. + for lock in [None, Some(json!({"position": true})), Some(json!({"transparency": true}))] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + if let Some(lock) = &lock { + s.execute("layer.lockLayers", lock.clone()).unwrap(); + } + let steps = s.active().unwrap().history.past_len(); + s.execute(cmd, p).unwrap(); + assert_eq!(smart(&s).smart_filters.len(), 1, "{cmd} {lock:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd} {lock:?}"); + } + } +} From cb3aef575344861edc516c280ba246da1433a486 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:29 +0200 Subject: [PATCH 041/223] Count tool: Marker Size and Label Size fields take effect (#4156) (#4124) Root cause: the options bar sent markerSize/labelSize as f32 JSON floats (8.0) and count.setGroup read them with as_u64, which is None for floats, so the command returned Ok and changed nothing. Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and actions send them), rejects fractions, negatives and non-numbers with BadParams, and clamps in u64/f64 so huge values no longer wrap through `as u32`. The options bar now sends integers. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/analysis_cmds.rs | 25 +++++++++++++--- crates/engine/src/analysis_cmds/tests.rs | 37 ++++++++++++++++++++++++ crates/ui-egui/src/analysis_ui.rs | 22 +++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 1ba04dfe4c..64af849e59 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -842,6 +842,23 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { Ok(count_json(doc(s)?.as_ref())) } +/// A size parameter (`markerSize`, `labelSize`) clamped to `lo..=hi`: `None` when absent or null. +/// Whole numbers sent as JSON floats (`8.0`, as UIs and recorded actions do) count; fractions, +/// negatives and non-numbers are bad params rather than silently ignored. Clamping happens in +/// `u64`/`f64`, so a huge value can't wrap through `as u32`. +fn whole_size(cmd: &str, p: &Value, key: &str, lo: u32, hi: u32) -> Result> { + let v = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + let n = match (v.as_u64(), v.as_f64()) { + (Some(n), _) => n.clamp(u64::from(lo), u64::from(hi)), + (None, Some(f)) if f.is_finite() && f >= 0.0 && f.fract() == 0.0 => f.clamp(f64::from(lo), f64::from(hi)) as u64, + _ => return Err(bad(cmd, format!("`{key}` = {v} must be a whole number ({lo}..={hi})"))), + }; + Ok(Some(u32::try_from(n).unwrap_or(hi))) +} + fn count_set_group(s: &mut Session, p: &Value) -> Result { const CMD: &str = "count.setGroup"; let d = doc(s)?; @@ -853,11 +870,11 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(c) = color_of(p, "color") { grp.color = c; } - if let Some(v) = p.get("markerSize").and_then(Value::as_u64) { - grp.marker_size = (v as u32).clamp(1, 10); + if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { + grp.marker_size = v; } - if let Some(v) = p.get("labelSize").and_then(Value::as_u64) { - grp.label_size = (v as u32).clamp(8, 72); + if let Some(v) = whole_size(CMD, p, "labelSize", 8, 72)? { + grp.label_size = v; } if let Some(v) = p.get("visible").and_then(Value::as_bool) { grp.visible = v; diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index c043665b11..e5272d558d 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -349,3 +349,40 @@ fn now_iso_shape() { assert_eq!(t.len(), 20, "{t}"); assert!(t.ends_with('Z') && &t[4..5] == "-"); } + +#[test] +fn count_set_group_takes_sizes_sent_as_whole_floats() { + // The options bar sends `{"markerSize": 8.0, "labelSize": 20.0}`; `as_u64` read neither, so + // the fields did nothing. + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 8.0, "labelSize": 20.0})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + // Out-of-range whole numbers clamp, also huge ones that `as u32` would have wrapped. + s.execute("count.setGroup", json!({"markerSize": 4_294_967_297u64, "labelSize": 1e300})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (10, 72)); +} + +#[test] +fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 3, "labelSize": 12})).unwrap(); + let past = s.active().unwrap().history.past_len(); + for p in [ + json!({"markerSize": 8.5}), + json!({"labelSize": 12.25}), + json!({"markerSize": -2}), + json!({"labelSize": -9.0}), + json!({"markerSize": "8"}), + json!({"labelSize": true}), + ] { + let err = s.execute("count.setGroup", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}"); + } + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index b0ad27bdd3..09aaf17670 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -565,7 +565,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ui.label(RichText::new(tl!("Label Size")).color(t.text_dim).size(11.0)); let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).changed(); if a || b { - let _ = app.run("count.setGroup", json!({"markerSize": ms.round(), "labelSize": ls.round()})); + let _ = app.run("count.setGroup", count_size_params(ms, ls)); } } true @@ -613,6 +613,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // ------------------------------------------------------------------ panels and dialogs +/// `count.setGroup` params for the options bar's Marker Size and Label Size fields, sent as JSON +/// integers (the fields edit `f32`s; `8.0` is a float on the wire). +fn count_size_params(marker: f32, label: f32) -> Value { + let whole = |v: f32, lo: f32, hi: f32| if v.is_finite() { v.round().clamp(lo, hi) as u32 } else { lo as u32 }; + json!({"markerSize": whole(marker, 1.0, 10.0), "labelSize": whole(label, 8.0, 72.0)}) +} + fn float_frame(t: &Tokens) -> egui::Frame { egui::Frame::NONE .fill(t.card) @@ -1223,4 +1230,17 @@ pub(crate) mod tests { assert!(empty.run("view.colorSamplers.add", json!({"x": 1, "y": 1})).is_err()); assert!(empty.run("view.colorSamplers.list", json!({})).is_err()); } + + #[test] + fn count_size_fields_send_whole_numbers_the_engine_applies() { + let p = count_size_params(7.6, 20.0); + assert_eq!(p, json!({"markerSize": 8, "labelSize": 20})); + assert!(p["markerSize"].is_u64() && p["labelSize"].is_u64()); + assert_eq!(count_size_params(f32::NAN, 500.0), json!({"markerSize": 1, "labelSize": 72})); + let (mut app, _ctx) = app(); + app.run("count.add", json!({"x": 10, "y": 10})).unwrap(); + app.run("count.setGroup", count_size_params(8.0, 20.0)).unwrap(); + let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + } } From a8a30aed33facd158e14ebc4220c03a62b26fc60 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:34 +0200 Subject: [PATCH 042/223] Layers panel: show the lock icon for every lock (#4158) (#4159) Root cause: the layer row's reserved width and indicators, and the Studio themes' lock button, treated a layer as locked only for transparency, position or all. A "Lock image pixels" or artboard lock left the row without its lock icon, and the Studio button drew "unlocked" although clicking it unlocked the layer (simple_lock_toggle checked all five). Fix: add Locks::any() in photocraft-doc and use it in all four places, so every lock shows the icon, as in Photoshop. Tests cover each lock on the row (reserved width and Indicator::Lock) and the Studio button state. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/lib.rs | 18 +++++++++ crates/ui-egui/src/layer_row_ui.rs | 4 +- crates/ui-egui/src/layer_row_ui/tests.rs | 47 +++++++++++++++++++++++- crates/ui-egui/src/panels.rs | 12 +++--- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 354de06d31..e2a46e4079 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,6 +99,11 @@ pub struct Locks { } impl Locks { + /// Whether any lock is set: Photoshop then shows the lock icon on the layer's row. + pub fn any(self) -> bool { + self.transparency || self.pixels || self.position || self.artboard || self.all + } + pub fn union(self, o: Locks) -> Locks { Locks { transparency: self.transparency || o.transparency, @@ -1195,6 +1200,19 @@ mod tests { Document::with_background("t", Size::new(100, 50), ColorMode::Rgb, SampleType::U8, Color::WHITE) } + #[test] + fn any_lock_counts_every_lock() { + assert!(!Locks::default().any()); + let each = [ + Locks { transparency: true, ..Locks::default() }, + Locks { pixels: true, ..Locks::default() }, + Locks { position: true, ..Locks::default() }, + Locks { artboard: true, ..Locks::default() }, + Locks { all: true, ..Locks::default() }, + ]; + assert!(each.iter().all(|k| k.any())); + } + #[test] fn unused_link_group_never_wraps() { let mut d = doc(); diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index fb229fcbc8..7ac725a7cb 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -71,7 +71,7 @@ pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(In /// Width the always-shown indicators of `l` take at the row's right (blend label excluded). pub fn reserved_width(l: &Layer) -> f32 { let mut w = RIGHT_PAD; - if l.locks.transparency || l.locks.position || l.locks.all { + if l.locks.any() { w += ICON_W + GAP; } if !l.effects.items.is_empty() { @@ -113,7 +113,7 @@ pub fn indicators( actions: &mut Vec<(String, Value)>, ) -> (f32, Vec<(Indicator, Rect)>, bool) { let t = Tokens::get(ui.ctx()); - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); let fx_font = theme::semibold(11.0); let blend_font = FontId::proportional(10.5); let has_fx = !l.effects.items.is_empty(); diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index f2ac633ba2..e134897cf9 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -3,7 +3,7 @@ use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_doc::{Effect, LayerContent}; use serde_json::json; @@ -536,3 +536,48 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { let effects = open[0].fields["effects"].as_array().unwrap(); assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}"); } + +/// One layer per lock, each with only that lock set (#4158). +fn one_lock_each() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let mut ids = Vec::new(); + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let id = s.execute("layer.new.layer", json!({"name": key})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locks": {key: true}})).unwrap(); + ids.push((key, id)); + } + (s, ids) +} + +#[test] +fn every_lock_shows_the_lock_icon_on_the_row() { + // #4158: "Lock image pixels" and the artboard lock left the row without its lock icon. + // Photoshop shows it whenever any lock is set. + let (s, ids) = one_lock_each(); + let doc = &s.active().unwrap().doc; + for &(key, id) in &ids { + let l = doc.layers.iter().find(|l| l.id.0 == id).unwrap(); + assert_eq!(super::reserved_width(l), super::RIGHT_PAD + super::ICON_W + super::GAP, "{key}: room for the lock icon"); + } + let h = harness(s, 1.0, "promedium", 290.0); + let rows = recorded(&h.ctx); + for &(key, id) in &ids { + let r = rows.iter().find(|r| r.layer == id).unwrap_or_else(|| panic!("{key}: row drawn")); + assert!(r.indicators.iter().any(|(k, _)| *k == Indicator::Lock), "{key}: lock icon drawn ({:?})", r.indicators); + } +} + +#[test] +fn the_studio_lock_button_shows_every_lock() { + // #4158: the simple themes' lock button drew "unlocked" for a pixels or artboard lock, though + // clicking it then unlocked the layer. + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": key})).unwrap(); + s.execute("layer.setProps", json!({"locks": {key: true}})).unwrap(); + let h = harness(s, 1.0, "studio", 290.0); + assert_eq!(h.get_by_label("Lock layer").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{key}"); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bb4d2f1e90..f0e0046711 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2013,9 +2013,8 @@ fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } - let k = &l.locks; - let locked = k.transparency || k.pixels || k.position || k.artboard || k.all; - let locks = if locked { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; + let locks = + if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) } @@ -2174,7 +2173,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } if !t.pro { - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { actions.push(simple_lock_toggle(bg, l)); } @@ -4604,7 +4603,7 @@ mod lock_tests { click_lock(&mut app); let l = &app.session.active().unwrap().doc.layers[0]; assert_eq!(l.name, "Layer 0"); - assert!(!(l.locks.transparency || l.locks.pixels || l.locks.position || l.locks.all)); + assert!(!l.locks.any()); erase_line(&mut app); assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(app.session.active().unwrap().doc.layers[0].surface().unwrap().rgba(50, 40)[3], 0.0, "erased to transparency"); @@ -4612,8 +4611,7 @@ mod lock_tests { click_lock(&mut app); assert!(app.session.active().unwrap().doc.layers[0].locks.all); click_lock(&mut app); - let k = app.session.active().unwrap().doc.layers[0].locks; - assert!(!(k.transparency || k.pixels || k.position || k.artboard || k.all)); + assert!(!app.session.active().unwrap().doc.layers[0].locks.any()); // A layer with only transparency locked unlocks in one click. app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); click_lock(&mut app); From f4b73f42ed3795b0b77cef995e8335148c3ac9ce Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:40 +0200 Subject: [PATCH 043/223] Automation: reject an invalid document index instead of using the active document (#4160) (#4161) Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a string, negative, fractional or boolean index became None, which means "the active document". `doc.close {"index":"0"}` therefore closed the active (possibly unsaved) document instead of failing. The MCP server types the param as Option and already rejects these values. Fix: `index_of` returns Result, AutomationError>. A missing or null index still means the active document; a non-negative integer is used; anything else is a bad request ("`index` must be a non-negative integer"). All doc.* call sites propagate the error with `?`. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/automation/src/rpc.rs | 48 ++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 1599eb6a53..5d44f0e7a7 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -60,8 +60,13 @@ fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } -fn index_of(p: &Value) -> Option { - p.get("index").and_then(Value::as_u64).map(|i| i as usize) +/// The optional `index` param: missing or null means the active document; anything else must be a +/// non-negative integer (never silently fall back to the active document, which `doc.close` would close). +fn index_of(p: &Value) -> Result, AutomationError> { + match p.get("index") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")), + } } fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> { @@ -109,7 +114,7 @@ impl Headless { let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.place(index_of(&p), path, &Value::Object(opts)) + self.place(index_of(&p)?, path, &Value::Object(opts)) } "doc.new" => self.command_run("file.new", p), "doc.save" => { @@ -123,7 +128,7 @@ impl Headless { opts.encode.webp_lossless = false; } let path = str_of(&p, "path").map(PathBuf::from); - self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts) + self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts) } "doc.exportLayers" | "doc.exportDataSets" => { let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?; @@ -131,16 +136,16 @@ impl Headless { opts.remove("dir"); opts.remove("index"); let opts = Value::Object(opts); - if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) } + if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) } } "doc.importDataSets" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?; let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.import_data_sets(index_of(&p), path, &Value::Object(opts)) + self.import_data_sets(index_of(&p)?, path, &Value::Object(opts)) } - "doc.inspect" => self.inspect(index_of(&p)), + "doc.inspect" => self.inspect(index_of(&p)?), "doc.render" => { let max = match p.get("maxSide") { None => 1024, @@ -148,7 +153,7 @@ impl Headless { value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))? } }; - let png = self.render_png(index_of(&p), max)?; + let png = self.render_png(index_of(&p)?, max)?; match str_of(&p, "path") { Some(path) => { self.write_render(&PathBuf::from(path), &png)?; @@ -161,10 +166,10 @@ impl Headless { } } "doc.select" => { - let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?; + let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?; self.select(i) } - "doc.close" => self.close(index_of(&p)), + "doc.close" => self.close(index_of(&p)?), "batch" => self.batch(&p), "methods" => Ok(json!(METHODS)), other => Err(bad(format!("unknown method `{other}` (try `methods`)"))), @@ -830,4 +835,27 @@ mod tests { assert_eq!(v["error"], "authentication required"); assert!(v.get("result").is_none()); } + + #[test] + fn invalid_document_index_is_rejected_not_the_active_document() { + let h = session(); + for _ in 0..2 { + assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true); + } + let count = |h: &Mutex| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len(); + for index in [json!("0"), json!(-1), json!(0.5), json!(true)] { + for method in ["doc.close", "doc.inspect", "doc.select"] { + let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string()); + assert_eq!(reply["ok"], false, "{method} {index}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}"); + } + assert_eq!(count(&h), 2, "index {index} closed a document"); + } + // Null means "the active document", like a missing `index`. + assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true); + // A valid index still closes that document. + let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(count(&h), 1); + } } From a256f1c579769f12cf1f8032fc6d4a36b13f6e94 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:47 +0200 Subject: [PATCH 044/223] Paint Bucket and Gradient: validate opacity like the Magic Eraser (#4167) (#4168) paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100 with no range check, so 200 became a coverage of 2 and overshot the blend (#808080 on white painted near-black). Both now use the Magic Eraser's param reader: the value is clamped to 1..100, and a non-numeric opacity is BadParams before any edit or history step. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/eraser_cmds.rs | 4 +- crates/engine/src/paint_cmds.rs | 66 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 3fb546de4f..920c96a014 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -27,7 +27,9 @@ fn flag(p: &Value, k: &str, d: bool) -> bool { } /// A finite number param clamped to `range`, `default` when absent; non-numbers are an error. -fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { +/// A numeric param clamped to `range`; missing or null gives `default`, anything else not a +/// finite number is BadParams. Shared with the Paint Bucket and Gradient opacity. +pub(crate) fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { match p.get(k) { None | Some(Value::Null) => Ok(default), Some(v) => { diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index d97ca5b319..4dc39f1517 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -35,11 +35,18 @@ fn point(p: &Value, k: &str) -> Option<(f32, f32)> { Some((a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) } +/// `opacity` (1..100 %) as a coverage in 0.01..=1, clamped like the Magic Eraser's: a value +/// above 1 would overshoot the blend (#4167). +fn opacity(p: &Value, cmd: &str) -> Result { + Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0) +} + fn bucket(s: &mut Session, p: &Value) -> Result { let fg = s.tools.foreground; let c = color(p.get("color"), fg); let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32); - let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0); + let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); + let opacity = opacity(p, "paint.bucket")?; let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; // Other visible layers can define the region, but the fill still writes only to the target. let region = if all { @@ -117,7 +124,7 @@ fn gradient(s: &mut Session, p: &Value) -> Result { } }; let reverse = b(p, "reverse", false); - let opacity = f(p, "opacity", 100.0) / 100.0; + let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); let dither = b(p, "dither", true); // Photoshop dithers gradients by default (reduces banding). s.edit("Gradient", |doc, active| { @@ -407,4 +414,59 @@ mod tests { let noisy = run(true); assert!(noisy.windows(2).any(|w| w[0] != w[1]), "dither: per-pixel variation"); } + + /// The two commands under test, each painting #808080 over a white document. + fn grey_fill(cmd: &str, opacity: Value) -> Value { + match cmd { + "paint.bucket" => json!({"x": 2, "y": 2, "color": "#808080", "opacity": opacity}), + _ => json!({"from": [0, 0], "to": [19, 0], "colors": ["#808080", "#808080"], "dither": false, "opacity": opacity}), + } + } + + fn white(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10, "depth": depth, "background": "white"})).unwrap(); + s + } + + /// Issue #4167: an opacity above 100 overshot the blend (200 painted near-black on white). + #[test] + fn bucket_and_gradient_clamp_opacity_like_the_magic_eraser() { + for cmd in ["paint.bucket", "paint.gradient"] { + for depth in [8, 16, 32] { + let mut full = white(depth); + full.execute(cmd, grey_fill(cmd, json!(100))).unwrap(); + let expected = px(&full, 5, 5); + assert!((expected[0] - 128.0 / 255.0).abs() < 0.01, "{cmd} depth {depth}: 100% paints #808080, got {expected:?}"); + for over in [200.0, 1e9] { + let mut s = white(depth); + s.execute(cmd, grey_fill(cmd, json!(over))).unwrap(); + assert_eq!(px(&s, 5, 5), expected, "{cmd} depth {depth}: opacity {over} clamps to 100"); + } + let (mut low, mut one) = (white(depth), white(depth)); + low.execute(cmd, grey_fill(cmd, json!(-50))).unwrap(); + one.execute(cmd, grey_fill(cmd, json!(1))).unwrap(); + assert_eq!(px(&low, 5, 5), px(&one, 5, 5), "{cmd} depth {depth}: opacity below 1 clamps to 1"); + assert!(px(&low, 5, 5).iter().all(|v| (0.0..=1.0).contains(v)), "{cmd} depth {depth}: no overshoot"); + } + } + } + + #[test] + fn bucket_and_gradient_reject_non_numeric_opacity_without_editing() { + for cmd in ["paint.bucket", "paint.gradient"] { + for bad in [json!("50"), json!(true), json!([50]), json!({"v": 50})] { + let mut s = white(8); + let (before, steps) = (px(&s, 5, 5), s.active().unwrap().history.past_len()); + let err = s.execute(cmd, grey_fill(cmd, bad.clone())).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{cmd} {bad}: {err:?}"); + assert_eq!(px(&s, 5, 5), before, "{cmd} {bad}: pixels unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {bad}: no history step"); + } + // An explicit null keeps the default (100%), like the Magic Eraser. + let mut s = white(8); + s.execute(cmd, grey_fill(cmd, Value::Null)).unwrap(); + assert!((px(&s, 5, 5)[0] - 128.0 / 255.0).abs() < 0.01, "{cmd}: null opacity is 100%"); + } + } } From e4bfa7907f77b62c21bd1a61f66427f43b18ffcc Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:52 +0200 Subject: [PATCH 045/223] Video layers: edits keep the timeline work area (#4170) (#4171) Every video-layer edit ran `work_end = work_end.max(duration)`, so Show Altered Video, Restore Frame, Interpret Footage and the other frame edits reset a narrowed work area to the full duration. A new `grow_duration` helper, used by every video edit and by New Video Layer from File, grows the timeline and moves the work-area end only when it reached the old end; a narrowed work area stays where the user put it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/video_cmds.rs | 18 +++++-- crates/engine/src/video_cmds/tests.rs | 68 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index dbb51d82d2..2a9f8ae8b4 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -72,6 +72,17 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } +/// Grow the timeline to at least `frames` frames (never shrink it). Only a work area that reached +/// the old end follows the new end; a narrowed one stays where the user put it (#4170). +fn grow_duration(t: &mut Timeline, frames: usize) { + let old = t.duration; + t.duration = t.duration.max(frames.max(1)); + if t.work_end >= old { + t.work_end = t.duration; + } + t.clamp(); +} + /// Edit the active video layer's [`VideoData`] at the playhead, then re-sync the displayed frame. fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize) -> Result<()>) -> Result { let count = s.edit(label, |doc, active| { @@ -83,9 +94,7 @@ fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize f(v, cur)?; let n = v.frames.len(); if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n.max(1)); - t.work_end = t.work_end.max(t.duration); - t.clamp(); + grow_duration(t, n); } sync(doc); Ok(n) @@ -295,8 +304,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { if doc.timeline.is_none() { doc.timeline = Some(Timeline::new(n, fps)); } else if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n); - t.clamp(); + grow_duration(t, n); } let name = doc.next_layer_name(std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).unwrap_or("Video")); let mut l = Layer::raster(name, fmt); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 3380d3b9d0..22d639d49b 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -430,3 +430,71 @@ fn restoring_blank_video_frames_still_produces_blank_frames() { s.execute("layer.videoLayers.restoreFrame", json!({})).unwrap(); assert_eq!(layer_px(&s, id)[3], 0.0); } + +fn work_area(s: &Session) -> (usize, usize, usize) { + let t = s.active().unwrap().doc.timeline.as_ref().unwrap(); + (t.work_start, t.work_end, t.duration) +} + +#[test] +fn video_edits_keep_a_narrowed_work_area() { + // #4170: a video edit pushed the work-area end to the full duration. + for (cmd, params) in [ + ("layer.videoLayers.showAlteredVideo", json!({})), + ("layer.videoLayers.restoreFrame", json!({})), + ("layer.videoLayers.interpretFootage", json!({"fps": 24})), + ] { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30)); + s.execute(cmd, params).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30), "{cmd} moved the work area"); + } +} + +#[test] +fn a_full_work_area_grows_with_the_duration() { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 30, 30)); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 31, 31)); + // A narrowed work area stays put when the duration grows. + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 32)); +} + +#[test] +fn longer_footage_grows_only_a_full_work_area() { + let dir = tmpdir("workarea"); + for i in 0..5 { + write_png(&format!("{dir}/f{i:02}.png"), 16, 12, "#808080"); + } + // A work area covering everything follows the longer footage. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (0, 5, 5)); + // A narrowed one stays put. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 1, "workEnd": 2})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (1, 2, 5)); +} + +#[test] +fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { + let mut t = Timeline::new(10, 30.0); + grow_duration(&mut t, 4); + assert_eq!((t.duration, t.work_end), (10, 10)); + grow_duration(&mut t, 0); + assert_eq!((t.duration, t.work_end), (10, 10)); + t.work_end = 6; + grow_duration(&mut t, 12); + assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); +} From 43c7584d5da0539412b92fe75ac2d49ca7937513 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:56 +0200 Subject: [PATCH 046/223] Warp: move only the selected part of a linked mask (#4175) (#4176) With a selection, Edit > Transform > Warp warped only the selected pixels but the whole linked layer mask, so the unselected mask no longer lined up with the unselected pixels. Warp now limits the linked mask to the selection through transform_cmds::move_gray_selected, as Free Transform and Perspective Warp already do (#3083); layers that move whole still take their whole mask along. Adds a regression test driven by edit.transform.warp. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/warp_cmds.rs | 59 +++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 844da50345..e64c93279d 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -118,6 +118,9 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } let map = |x: f64, y: f64| w.map(x, y); + // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform + // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. + let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -174,7 +177,11 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, if let Some(m) = l.mask.as_mut() && m.linked { - m.surface = warp_mesh_gray(&m.surface, &map, interp); + let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp)); + m.surface = match mask_sel { + Some(sel) => crate::transform_cmds::move_gray_selected(&m.surface, sel, &|lifted, src| Ok(warp_mesh_surface(lifted, src, &map, interp)), &whole)?, + None => whole(&m.surface)?, + }; } if let Some(vm) = l.vector_mask.as_mut() && vm.linked @@ -750,4 +757,54 @@ mod tests { let back: Warp = serde_json::from_value(r["warp"].clone()).unwrap(); assert_eq!(back.mesh.unwrap().us.len(), 2); } + + /// A selection limits the warp of a linked mask too, as it does the pixels (#4175; Free + /// Transform and Perspective Warp did this already, #3083): the moved part of the mask lines + /// up with the moved pixels and the unselected part stays put. + #[test] + fn linked_mask_with_a_selection_warps_only_the_selected_region() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 20), &[1.0, 0.0, 0.0, 1.0]); + // Hide the left quarter (selected) and the right quarter (not selected). + let m = l.mask.as_mut().unwrap(); + m.surface.fill_rect(Rect::new(10, 10, 15, 20), &[0.0]); + m.surface.fill_rect(Rect::new(25, 10, 30, 20), &[0.0]); + m.linked = true; + Ok(()) + }) + .unwrap(); + let mask_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) + }; + let alpha_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3] + }; + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + // A mesh that translates the selected box 50 px down. + let b = [10.0, 10.0, 20.0, 20.0]; + let mesh = BezierMesh::identity(b, 1, 1).map_points(|p| [p[0], p[1] + 50.0]); + s.execute("edit.transform.warp", json!({"rect": [10, 10, 20, 20], "mesh": mesh})).unwrap(); + // Moved pixels and their mask line up. + assert!(alpha_at(&s, 12, 65) > 0.99); + assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved"); + assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved"); + // The vacated area reads as revealed, with transparent pixels. + assert!(alpha_at(&s, 12, 15) < 0.01); + assert!(mask_at(&s, 12, 15) > 0.99); + // The unselected part of the mask stays, aligned with the unselected pixels. + assert!(alpha_at(&s, 27, 15) > 0.99); + assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put"); + assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along"); + for (x, y) in [(11, 61), (13, 68), (16, 61), (18, 68)] { + let want = if x < 15 { 0.0 } else { 1.0 }; + assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y}): {}", mask_at(&s, x, y)); + } + } } From 0fe109f7b21211196f36bf07254433ae918860af Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:38:01 +0200 Subject: [PATCH 047/223] Contours (.shc): keep long names valid and readable (#4177) (#4179) The writer capped names with encode_utf16().take(1024), which could end on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase, so the cap falls on a character boundary. The reader's "count left out the null" check looked at the last unit kept, not the last unit read. For a name of 1024+ units the counted null is read but not kept, so a following 0x0000 (an empty contour's point count) was swallowed and the stream shifted. It now checks the last unit actually read. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/psd/src/shc.rs | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/psd/src/shc.rs b/crates/psd/src/shc.rs index dfe4855719..135fd33e62 100644 --- a/crates/psd/src/shc.rs +++ b/crates/psd/src/shc.rs @@ -50,14 +50,17 @@ fn read_name(r: &mut Reader<'_>) -> Result { r.check_count(n, 2)?; let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("contour name length"))?; let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); + // The last unit read, not the last one kept: past the cap the counted null is read but not kept. + let mut last = None; for _ in 0..n { let u = r.u16()?; + last = Some(u); if units.len() < MAX_NAME_UNITS { units.push(u); } } - // A count that leaves out the terminator: the null follows (a point count is never 0). - if units.last() != Some(&0) && r.peek_rest().get(..2) == Some(&[0, 0]) { + // A count that leaves out the terminator: the null follows. + if last != Some(0) && r.peek_rest().get(..2) == Some(&[0, 0]) { r.u16()?; } while units.last() == Some(&0) { @@ -128,7 +131,8 @@ pub fn write(contours: &[ShcContour]) -> Result> { for c in contours { let n = u16::try_from(c.points.len()).ok().filter(|n| usize::from(*n) <= MAX_POINTS).ok_or(PsdError::LimitExceeded("too many points in a contour"))?; out.put_u32(2); - let units: Vec = c.name.encode_utf16().take(MAX_NAME_UNITS).collect(); + // Capped on a character boundary, so a surrogate pair is never split. + let units = crate::io::utf16_units(&c.name, MAX_NAME_UNITS); out.put_u32(units.len() as u32 + 1); for u in units { out.put_u16(u); @@ -195,4 +199,53 @@ mod tests { let many = ShcContour { name: String::new(), points: vec![ShcPoint { input: 0.0, output: 0.0, continuous: true }; MAX_POINTS + 1] }; assert!(write(&[many]).is_err()); } + + fn pts(n: usize) -> Vec { + (0..n).map(|i| ShcPoint { input: (i * 10) as f32, output: (i * 20) as f32, continuous: i % 2 == 0 }).collect() + } + + #[test] + fn shc_name_cap_never_splits_a_surrogate_pair() { + let name = format!("{}\u{1F600}", "a".repeat(MAX_NAME_UNITS - 1)); + let c = ShcContour { name, points: pts(2) }; + let back = parse(&write(&[c]).unwrap()).unwrap(); + assert_eq!(back[0].name, "a".repeat(MAX_NAME_UNITS - 1)); + } + + #[test] + fn shc_names_at_and_over_the_cap_keep_the_stream_in_sync() { + for len in [MAX_NAME_UNITS, MAX_NAME_UNITS + 1, MAX_NAME_UNITS * 3] { + for first_points in [0, 3] { + let long = ShcContour { name: "b".repeat(len), points: pts(first_points) }; + let next = ShcContour { name: "Next".into(), points: pts(4) }; + let back = parse(&write(&[long, next.clone()]).unwrap()).unwrap_or_else(|e| panic!("len {len}, points {first_points}: {e}")); + assert_eq!(back.len(), 2); + assert_eq!(back[0].name, "b".repeat(len.min(MAX_NAME_UNITS))); + assert_eq!(back[0].points.len(), first_points); + assert_eq!(back[1], next, "len {len}, points {first_points}"); + } + } + } + + #[test] + fn shc_name_count_without_null_at_the_cap_still_reads() { + // A hostile/foreign file: 1024 units counted, no null counted, null follows. + let mut v = b"8BFS".to_vec(); + v.put_u16(1); + v.put_u32(1); + v.put_u32(1); + v.put_u32(MAX_NAME_UNITS as u32); + for _ in 0..MAX_NAME_UNITS { + v.put_u16(u16::from(b'c')); + } + v.put_u16(0); + v.put_u16(1); + v.put_u16(7); + v.put_u16(9); + v.put_u32(0); + v.put_u32(255); + let c = parse(&v).unwrap(); + assert_eq!(c[0].name, "c".repeat(MAX_NAME_UNITS)); + assert_eq!((c[0].points[0].input, c[0].points[0].output), (9.0, 7.0)); + } } From 2f0979f61f3bcdef408df66f2d19446f9dedceda Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:38:05 +0200 Subject: [PATCH 048/223] Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182) layer.layerStyle.globalLight cast its params straight to f32, so an angle of 1e39 became inf and rem_euclid turned it into NaN, which was stored, recorded as a history step and fed to every effect using global light. Angle and altitude are now read as f64 and rejected with BadParams when non-finite or beyond f32 range, before the edit. In-range wrapping and the altitude clamp are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/layer_menu_cmds.rs | 10 ++++++++-- crates/engine/src/layer_menu_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index a895210c19..366f9c8d39 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -612,8 +612,14 @@ fn blend_range(v: &Value) -> Option { fn global_light(s: &mut Session, p: &Value) -> Result { let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light; - let angle = num(p, "angle", cur.angle); - let altitude = num(p, "altitude", cur.altitude).clamp(0.0, 90.0); + // Read as f64 and reject what f32 can't hold: 1e39 would become inf, and rem_euclid NaN. + let f32_param = |key: &str, default: f32| match p.get(key).and_then(Value::as_f64) { + None => Ok(default), + Some(v) if v.is_finite() && v.abs() <= f64::from(f32::MAX) => Ok(v as f32), + Some(_) => Err(bad("layer.layerStyle.globalLight", format!("{key} must be a finite number"))), + }; + let angle = f32_param("angle", cur.angle)?; + let altitude = f32_param("altitude", cur.altitude)?.clamp(0.0, 90.0); let angle = (angle + 180.0).rem_euclid(360.0) - 180.0; s.edit("Global Light", |doc, _| { doc.global_light.angle = angle; diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 51ad90500f..708bee5fce 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,24 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn global_light_rejects_numbers_beyond_f32() { + let mut s = session(8, "rgb"); + // In-range values still wrap into -180..=180 and clamp to 0..=90. + s.execute("layer.layerStyle.globalLight", json!({"angle": 450, "altitude": 120})).unwrap(); + assert_eq!((doc(&s).global_light.angle, doc(&s).global_light.altitude), (90.0, 90.0)); + s.execute("layer.layerStyle.globalLight", json!({"angle": 540, "altitude": 30})).unwrap(); + assert_eq!((doc(&s).global_light.angle.abs(), doc(&s).global_light.altitude), (180.0, 30.0)); + let before = doc(&s).global_light; + let steps = s.active().unwrap().history.past_len(); + // 1e39 overflowed f32 to inf, which rem_euclid turned into a stored NaN (#4181). + for p in [json!({"angle": 1e39}), json!({"angle": -1e39}), json!({"altitude": 1e39}), json!({"altitude": -1e39})] { + assert!(s.execute("layer.layerStyle.globalLight", p.clone()).is_err(), "{p}"); + assert_eq!(doc(&s).global_light, before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } +} + #[test] fn blending_options_blend_if() { for depth in DEPTHS { From 137d7c2df14b6939db47bce19b72a9e6190a6c1c Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 19:44:01 -0500 Subject: [PATCH 049/223] Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548) #4093 changed the Auto-Select default to Group, as in Photoshop. The two layout_perf tests that click layers inside groups with Auto-Select on expected the layer under the pointer, so they now select the group and fail on main. They set the target to Layer explicitly, which is what they test. --- crates/ui-egui/tests/layout_perf.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/layout_perf.rs index 502390c979..fb17833e18 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/layout_perf.rs @@ -132,6 +132,9 @@ fn auto_select_moves_the_layer_under_the_pointer() { actual_pixels(&mut h); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + // The default target is Group (#4056); these layers sit in groups, and the test is about the + // layer under the pointer. + h.state_mut().ui.tool_options.move_target = "layer".into(); h.state_mut().run("layer.select", json!({"layer": k.pixel.0})).expect("select"); h.run_steps(2); let (pixel_before, text_before) = (bounds(&h, k.pixel), bounds(&h, k.text)); @@ -150,6 +153,7 @@ fn a_move_tool_click_selects_without_moving() { let (mut h, k) = app(); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + h.state_mut().ui.tool_options.move_target = "layer".into(); for id in [k.text, k.shape, k.pixel, k.text] { let before = (bounds(&h, id), h.state().session.active().expect("doc").history.entries().len()); let p = screen(&h, point_on(&h, id)); From e627b011692bff909935ac8a8e7c03e5caab0a09 Mon Sep 17 00:00:00 2001 From: Tiimo Schulze Date: Tue, 13 Oct 2026 02:50:00 +0200 Subject: [PATCH 050/223] feat: resize Save for Web previews and custom palette editing for Gif export (#4129) * feat: resize Save for Web previews and added palette editing for Gif/PNG8 * Save for Web: add Turkish and Hungarian rows for the preview navigation hint Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/examples/bench_web_palette.rs | 30 ++ crates/engine/src/web_cmds.rs | 107 +++- .../engine/src/web_cmds/color_table_tests.rs | 88 ++++ crates/engine/src/web_cmds/tests.rs | 3 + crates/ui-egui/src/dialogs.rs | 50 +- crates/ui-egui/src/file_ui.rs | 466 ++++++++++++------ crates/ui-egui/src/file_ui/web_preview.rs | 323 ++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/control-protocol.md | 1 + 27 files changed, 908 insertions(+), 179 deletions(-) create mode 100644 crates/engine/examples/bench_web_palette.rs create mode 100644 crates/engine/src/web_cmds/color_table_tests.rs create mode 100644 crates/ui-egui/src/file_ui/web_preview.rs diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs new file mode 100644 index 0000000000..5ed1c3a3b0 --- /dev/null +++ b/crates/engine/examples/bench_web_palette.rs @@ -0,0 +1,30 @@ +//! Compare generated-palette and edited fixed-palette GIF exports on a 24 MP image. +//! cargo run --release -p photocraft-engine --example bench_web_palette +use std::time::Instant; + +use photocraft_engine::web_cmds::{WebSettings, optimize}; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() -> Result<(), Box> { + let (w, h) = (6000usize, 4000usize); + let mut pixels = Vec::new(); + pixels.try_reserve_exact(w * h)?; + for y in 0..h { + for x in 0..w { + pixels.push([x as f32 / w as f32, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0]); + } + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; + let rect = Rect::new(0, 0, w as i32, h as i32); + let started = Instant::now(); + let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); + let mut edited = settings; + edited.color_table = Some(original.color_table); + edited.color_table_edits.insert(0, [0, 255, 0]); + let started = Instant::now(); + let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); + Ok(()) +} diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index a15cb258cf..cccddbf9f3 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -79,6 +79,10 @@ pub struct WebSettings { pub format: WebFormat, pub palette: PaletteKind, pub colors: usize, + /// Fixed opaque source palette in index order. + pub color_table: Option>, + /// Replacements applied after quantization, preserving pixel indices. + pub color_table_edits: std::collections::BTreeMap, pub dither: Dither, /// 0..=1. pub dither_amount: f32, @@ -104,6 +108,8 @@ impl Default for WebSettings { format: WebFormat::Png24, palette: PaletteKind::Selective, colors: 256, + color_table: None, + color_table_edits: Default::default(), dither: Dither::Diffusion, dither_amount: 0.88, transparency: true, @@ -196,6 +202,27 @@ impl WebSettings { m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), }; } + if let Some(table) = p.get("colorTable") { + let table = table.as_array().filter(|a| (1..=256).contains(&a.len())).ok_or_else(|| bad(cmd, "colorTable must contain 1–256 #rrggbb colours"))?; + st.color_table = Some( + table + .iter() + .map(|v| v.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTable entries must be #rrggbb colours"))) + .collect::>>()?, + ); + } + if let Some(edits) = p.get("colorTableEdits") { + let edits = edits + .as_object() + .filter(|m| m.len() <= 256) + .ok_or_else(|| bad(cmd, "colorTableEdits must be an object of palette indices to #rrggbb colours"))?; + let table = st.color_table.as_ref().ok_or_else(|| bad(cmd, "colorTableEdits requires colorTable"))?; + for (index, value) in edits { + let index = index.parse::().ok().filter(|i| *i < table.len()).ok_or_else(|| bad(cmd, "colorTableEdits index is outside colorTable"))?; + let color = value.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTableEdits values must be #rrggbb colours"))?; + st.color_table_edits.insert(index, color); + } + } Ok(st) } } @@ -233,10 +260,23 @@ impl WebSettings { WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); - json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}) + let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); + if let Some(table) = &self.color_table { + p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); + } + if !self.color_table_edits.is_empty() { + p["colorTableEdits"] = + json!(self.color_table_edits.iter().map(|(i, c)| (i.to_string(), rgb_hex(*c))).collect::>()); + } + p } } +/// A palette colour in command/control-channel notation. +pub fn rgb_hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + /// Save for Web's named presets. pub const PRESETS: [&str; 12] = [ "GIF 128 Dithered", @@ -324,6 +364,11 @@ pub struct Optimized { pub height: u32, /// Colours in the palette (indexed formats). pub colors: Option, + /// Opaque source palette before replacements (empty for non-indexed formats). + pub color_table: Vec<[u8; 3]>, + pub transparent_index: Option, + /// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled. + pub indices: Vec, /// What the optimised file looks like, straight RGBA8 (for the dialog's previews). pub preview: Vec, } @@ -359,13 +404,14 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { - let (w, h) = (rect.width() as usize, rect.height() as usize); - if w == 0 || h == 0 { - return Err(other("empty slice")); + if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) + { + return Err(other("slice is outside the pixel buffer")); } + let (w, h) = (rect.width() as usize, rect.height() as usize); let row = |y: i32| { let o = y as usize * bw; - &px[o + rect.x0 as usize..o + rect.x1 as usize] + px.get(o + rect.x0 as usize..o + rect.x1 as usize).unwrap_or(&[]) }; let region = || (rect.y0..rect.y1).flat_map(row); let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]); @@ -375,6 +421,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let (ww, hh) = (w as u32, h as u32); let e = |e: photocraft_codecs::CodecError| other(e); let mut look: Vec = Vec::new(); + let mut color_table = Vec::new(); + let mut transparent_index = None; + let mut indices = Vec::new(); let (bytes, colors) = match st.format { WebFormat::Png24 => { let alpha = st.transparency && region().any(|p| p[3] < 1.0); @@ -451,16 +500,22 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }) .collect(); let room = if transparent { st.colors.saturating_sub(1).max(2) } else { st.colors }; - // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). - let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); - let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; - let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); - if src.is_empty() { - src = work.clone(); - } - let mut pal = quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)?; - drop(src); - if st.web_snap > 0.0 { + let mut pal = if let Some(table) = &st.color_table { + if table.is_empty() || table.len() > if transparent { 255 } else { 256 } { + return Err(other("colorTable has too many entries for this image's transparency")); + } + table.clone() + } else { + // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). + let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); + let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; + let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); + if src.is_empty() { + src = work.clone(); + } + quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)? + }; + if st.web_snap > 0.0 && st.color_table.is_none() { let tol = (st.web_snap * 255.0) as u8; for c in &mut pal { let snap = c.map(|x| ((f32::from(x) / 51.0).round() * 51.0) as u8); @@ -472,16 +527,23 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O if transparent { pal.truncate(255); } + let first = pal.first().copied().ok_or_else(|| other("empty colour table"))?; + color_table = pal.clone(); let t = transparent.then(|| { - pal.push(pal[0]); + pal.push(first); pal.len() - 1 }); + transparent_index = t; let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t); + for (index, color) in &st.color_table_edits { + let slot = pal.get_mut(*index).filter(|_| Some(*index) != t).ok_or_else(|| other("colour edit is outside the opaque palette"))?; + *slot = *color; + } if preview { look = idx .iter() .flat_map(|k| { - let c = pal[*k as usize]; + let c = pal.get(*k as usize).copied().unwrap_or(first); [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] }) .collect(); @@ -492,10 +554,13 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } else { photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? }; + if preview { + indices = idx; + } (bytes, Some(pal.len())) } }; - Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, preview: look }) + Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, color_table, transparent_index, indices, preview: look }) } /// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each @@ -811,7 +876,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; - return Ok(json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok( + json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), + ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; @@ -1378,7 +1445,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs new file mode 100644 index 0000000000..1d319cb382 --- /dev/null +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn color_replacement_preserves_pixel_membership_and_transparency() { + let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.6, 0.0, 0.4, 1.0], [0.0, 0.0, 0.0, 0.0]]; + for format in ["gif", "png8"] { + for dither in ["none", "diffusion", "pattern", "noise"] { + let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); + let original = WebSettings::from_params(&p, "test").unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let mut edited = p.clone(); + // Replace red with blue: re-quantization would now send all opaque pixels to blue. + edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); + let settings = WebSettings::from_params(&edited, "test").unwrap(); + assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + if old[3] == 0 { + assert_eq!(new[3], 0); + } else if old[..3] == [255, 0, 0] { + assert_eq!(*new, [0, 0, 255, 255]); + } else { + assert_eq!(*new, [0, 255, 0, 255]); + } + } + assert_eq!(before.indices, after.indices, "palette edits must not remap pixels"); + assert_eq!(after.transparent_index, Some(2)); + assert_eq!(after.color_table, vec![[255, 0, 0], [0, 0, 255]]); + let decoded = photocraft_codecs::decode(&after.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + // Transparent RGB has no significance; opaque preview pixels must equal the file. + for (file, preview) in decoded.data().as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + assert_eq!(file[3], preview[3]); + if preview[3] != 0 { + assert_eq!(file, preview); + } + } + } + } +} + +#[test] +fn exported_palette_edits_work_at_every_document_depth() { + let dir = tmp("table-edits"); + for depth in [8, 16, 32] { + let mut s = session(depth); + let mut p = json!({"format": "gif", "colors": 16, "dither": "none"}); + let estimate = s.execute("file.export.saveForWebLegacy", p.clone()).unwrap(); + let table = estimate["colorTable"].as_array().unwrap(); + let red = table.iter().position(|c| c == "#ff0000").unwrap(); + p["colorTable"] = estimate["colorTable"].clone(); + p["colorTableEdits"] = json!({red.to_string(): "#00ff00"}); + let path = format!("{dir}/{depth}.gif"); + p["path"] = json!(path); + let original_doc = s.active().unwrap().doc.clone(); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + assert_eq!(s.active().unwrap().doc, original_doc, "export must not edit the document"); + let img = decode(&path).convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(&img.data()[(16 * 64 + 16) * 4..(16 * 64 + 16) * 4 + 4], &[0, 255, 0, 255]); + assert_eq!(img.data()[3], 0); + } +} + +#[test] +fn malformed_tables_and_out_of_bounds_slices_return_errors() { + for p in [ + json!({"colorTable": []}), + json!({"colorTable": "bad"}), + json!({"colorTable": ["#ff00é"]}), + json!({"colorTable": vec!["#000000"; 257]}), + json!({"colorTable": [123]}), + json!({"colorTableEdits": {"0": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"-1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": []}), + ] { + assert!(WebSettings::from_params(&p, "test").is_err(), "{p}"); + let mut s = session(8); + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute("file.export.saveForWebLegacy", p).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + } + let settings = WebSettings::default(); + for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); +} diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index f64e309e95..787560f425 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -656,3 +656,6 @@ fn overlapping_slices_keep_the_later_slice_in_html() { assert_eq!(area, 64 * 48); assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}"); } + +#[path = "color_table_tests.rs"] +mod color_table_tests; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b5e0113784..678941484d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -99,6 +99,33 @@ 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 show_modal( + modal: egui::Modal, + ctx: &egui::Context, + id: egui::Id, + resizable: bool, + interactive: bool, + contents: impl FnOnce(&mut egui::Ui) -> T, +) -> egui::ModalResponse { + modal.show(ctx, |ui| { + // Disable the entire older modal, including its resize grip, not just its body. + if !interactive { + ui.disable(); + } + if resizable { + let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); + egui::Resize::default() + .id(id.with("resize")) + .default_size(egui::vec2(960.0, 900.0).min(max)) + .min_size(egui::vec2(560.0, 400.0)) + .max_size(max) + .show(ui, contents) + } else { + contents(ui) + } + }) +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); @@ -147,11 +174,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tab / ⇧Tab walk the topmost dialog's text fields, not every widget (field_tab.rs). let tab = if top == Some(d.id) && !egui::Popup::is_any_open(ctx) { crate::field_tab::take_step(ctx) } else { 0 }; // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. - let modal = modal.show(ctx, |ui| { - if !interactive { - ui.disable(); - } + 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| { sizing = ui.is_sizing_pass(); + let available = ui.available_size(); ui.set_min_width(380.0); let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { @@ -174,7 +201,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } else { 440.0 })); - if let Some(w) = crate::file_ui::dialog_width(&d.fields) { + if web { + ui.set_max_width(available.x); + ui.set_min_size(available); + } else if let Some(w) = crate::file_ui::dialog_width(&d.fields) { ui.set_min_width(w); ui.set_max_width(w); } @@ -358,7 +388,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(ButtonRole::Cancel) => outcome = Some(false), Some(ButtonRole::Apply) => apply_requested = true, Some(_) => outcome = Some(true), - None if interactive && can_ok && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true), + None if interactive + && can_ok + && (!web || (!web_popup_open && !egui::Popup::is_any_open(ui.ctx()))) + && ui.input(|i| i.key_pressed(egui::Key::Enter)) => + { + outcome = Some(true) + } None => {} } } @@ -370,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // The picker has a larger frame margin; include that whole frame in the canvas hit guard. shown.push(if color_picker { modal.response.rect } else { modal.inner }); // Pin once laid out at its real size (the first frame is an invisible sizing pass). - if !sizing && (pinned.is_none() || drag != egui::Vec2::ZERO) { + if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) { let screen = ctx.content_rect(); // Keep the whole dialog (and so its title bar) on screen. let room = (screen.size() - modal.response.rect.size()).max(egui::Vec2::ZERO); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 310c9fea04..e3884796f8 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -180,6 +180,9 @@ fn b(f: &Map, k: &str, d: bool) -> bool { // ---------- Save for Web ---------- +#[path = "file_ui/web_preview.rs"] +mod web_preview; + const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; @@ -193,7 +196,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { - if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent") { + if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { f.insert(k, v); } } @@ -227,6 +230,10 @@ fn variants(p: &Value) -> Vec { let fmt = p["format"].as_str().unwrap_or("jpeg"); for k in [2.0, 4.0] { let mut v = p.clone(); + if let Some(m) = v.as_object_mut() { + m.remove("colorTable"); + m.remove("colorTableEdits"); + } if fmt == "jpeg" { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { @@ -241,51 +248,65 @@ fn variants(p: &Value) -> Vec { } /// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio. -type WebProxy = (Arc>, u32, u32, f64); +type WebProxy = (Arc>, u32, u32, f64, egui::Vec2); type WebCache = (String, Arc, Option<(usize, Option, &'static str)>); /// Renders one preview pane: the original (`None`) or an optimised variant. -fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, full_px: f64) { +fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let rev = st.revision; - // The proxy: the export size, at most 640 px on the long side. + // A bounded proxy; navigation never recomposites or re-encodes it. let base = p.cloned().unwrap_or_else(|| json!({})); - let size_sig = format!("{}|{}|{}", base["percent"], base["width"], base["height"]); - let wkey = egui::Id::new(("web-proxy", doc.id.0, rev, size_sig.clone())); - let proxy: Option = ui.data(|d| d.get_temp(wkey)); - let (px, w, h, ratio) = match proxy { + let size_sig = format!("{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["convertToSrgb"]); + let wkey = egui::Id::new(("web-proxy", doc.id.0, p.is_some())); + let proxy_sig = format!("{rev}|{size_sig}"); + let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey)); + let proxy = cached_proxy.filter(|(sig, _)| sig == &proxy_sig).map(|(_, proxy)| proxy); + let (px, w, h, ratio, source_size) = match proxy { Some(v) => v, None => { - let pct = base["percent"].as_f64().unwrap_or(100.0).max(1.0) / 100.0; - let (fw, fh) = (f64::from(doc.size.width) * pct, f64::from(doc.size.height) * pct); - let k = (640.0 / fw.max(fh)).min(1.0); - let st = WebSettings::default(); + let (fw, fh) = match web_preview::dimensions(&doc, &base) { + Ok(size) => size, + Err(error) => { + ui.label(error); + return; + } + }; + let k = (2048.0 / fw.max(fh)).min(1.0); + let st = WebSettings::from_params(&base, "preview").unwrap_or_default(); let Ok((wd, _, _)) = web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": "bilinear"}), &st) else { return; }; let buf = photocraft_compose::flatten(&wd); - let v = (Arc::new(buf.px), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0)); - ui.data_mut(|d| d.insert_temp(wkey, v.clone())); + let v = ( + Arc::new(buf.px), + wd.size.width, + wd.size.height, + (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0), + egui::vec2(fw as f32, fh as f32), + ); + ui.data_mut(|d| d.insert_temp(wkey, (proxy_sig, v.clone()))); v } }; - let sig = format!("{}{}", size_sig, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); - let key = egui::Id::new(("web-pane", doc.id.0, rev, sig.clone())); + let sig = format!("{}|{rev}|{size_sig}|{}", doc.id.0, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); + let key = ui.next_auto_id().with("web-pane"); let cached: Option = ui.data(|d| d.get_temp(key)); let (tex, info) = match cached.filter(|c| c.0 == sig) { Some((_, tex, info)) => (tex, info), None => { let (rgba, info) = match p { None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), - Some(p) => match WebSettings::from_params(p, "preview") - .and_then(|st| web_cmds::optimize(&px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true)) - { - Ok(Optimized { preview, bytes, colors, ext, .. }) => (preview, Some(((bytes.len() as f64 * ratio) as usize, colors, ext))), - Err(_) => (vec![0; (w * h * 4) as usize], None), + Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{size_sig}", doc.id.0), p, &px, w, h) { + Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))), + Err(error) => { + ui.label(error); + (vec![0; (w * h * 4) as usize], None) + } }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); @@ -295,14 +316,15 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } }; ui.vertical(|ui| { - let (r, _) = ui.allocate_exact_size(size - egui::vec2(0.0, 34.0), egui::Sense::hover()); + ui.set_width(size.x); + let (r, response) = ui.allocate_exact_size((size - egui::vec2(0.0, 42.0)).max(egui::vec2(1.0, 1.0)), egui::Sense::drag()); + let response = response.on_hover_cursor(egui::CursorIcon::Grab); + if let Some(rects) = f.get_mut("__previewRects").and_then(Value::as_array_mut) { + rects.push(json!([r.left(), r.top(), r.right(), r.bottom()])); + } crate::widgets::checker(ui.painter(), r, 8.0); - let ts = tex.size_vec2(); - let k = (r.width() / ts.x).min(r.height() / ts.y).min(1.0); - let ir = egui::Rect::from_center_size(r.center(), ts * k); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); - ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let _ = full_px; + web_preview::paint(ui, r, &response, &tex, source_size, f); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); let (l1, l2) = match (p, info) { (None, _) => ("Original".to_string(), format!("{} · {} × {} px", doc.name, doc.size.width, doc.size.height)), (Some(_), Some((bytes, colors, ext))) => { @@ -315,8 +337,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } (Some(_), None) => ("—".into(), String::new()), }; - ui.label(egui::RichText::new(l1).color(t.text).size(11.5)); - ui.label(egui::RichText::new(l2).color(t.text_dim).size(11.0)); + let l1 = if ratio > 1.001 { format!("{l1} · Proxy preview") } else { l1 }; + ui.add(egui::Label::new(egui::RichText::new(l1).color(t.text).size(11.5)).truncate()); + ui.add(egui::Label::new(egui::RichText::new(l2).color(t.text_dim).size(11.0)).truncate()); }); } @@ -357,158 +380,185 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map web_pane(app, ui, area, None, full_px), - "optimized" => web_pane(app, ui, area, Some(&p), full_px), + "original" => web_pane(app, ui, area, None, f), + "optimized" => web_pane(app, ui, area, Some(&p), f), "2up" => { ui.horizontal(|ui| { - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, full_px); - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), full_px); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, f); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), f); }); } _ => { let v = variants(&p); - let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 2.0); + let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 4.0); ui.horizontal(|ui| { - web_pane(app, ui, cell, None, full_px); - web_pane(app, ui, cell, Some(&v[0]), full_px); + web_pane(app, ui, cell, None, f); + web_pane(app, ui, cell, Some(&v[0]), f); }); ui.horizontal(|ui| { - web_pane(app, ui, cell, Some(&v[1]), full_px); - web_pane(app, ui, cell, Some(&v[2]), full_px); + web_pane(app, ui, cell, Some(&v[1]), f); + web_pane(app, ui, cell, Some(&v[2]), f); }); } } }); ui.add_space(10.0); - // Settings. + // Settings scroll independently on smaller windows. ui.vertical(|ui| { ui.set_width(250.0); - ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); - let mut preset = s(f, "__preset", ""); - let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; - opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); - if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { - if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { - f.extend(m); - } - f.insert("__preset".into(), json!(preset)); - } - ui.add_space(4.0); - dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); - ui.add_space(4.0); - let fmt = s(f, "format", "jpeg"); - match fmt.as_str() { - "gif" | "png8" => { - dropdown_str( - ui, - "web-palette", - f, - "palette", - &[ - ("perceptual", tl!("Perceptual")), - ("selective", tl!("Selective")), - ("adaptive", tl!("Adaptive")), - ("restrictive", tl!("Restrictive (Web)")), - ("exact", tl!("Exact")), - ], - 150.0, - ); - number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], - 150.0, - ); - number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); - } - "jpeg" => { - number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); - check(ui, f, "progressive", tl!("Progressive"), false); - check(ui, f, "optimized", tl!("Optimized"), true); - check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); - } - "wbmp" => { - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], - 150.0, - ); - } - _ => { - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - } - } - if fmt != "png24" || !b(f, "transparency", true) { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); - let mut m = s(f, "matte", "#ffffff"); - if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { - f.insert("matte".into(), json!(m)); + egui::ScrollArea::vertical().id_salt("web-settings").max_height(area.y).show(ui, |ui| { + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); + let mut preset = s(f, "__preset", ""); + let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; + opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); + if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { + if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { + f.extend(m); } - }); - } - ui.add_space(6.0); - check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); - dropdown_str( - ui, - "web-meta", - f, - "metadata", - &[("none", tl!("None")), ("copyright", tl!("Copyright")), ("copyrightAndContact", tl!("Copyright and Contact Info")), ("all", tl!("All"))], - 160.0, - ); - }); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); - number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); - let pct = n(f, "percent", 100.0) / 100.0; - ui.label( - egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) - .color(t.text_dim) - .size(11.5), - ); - let nslices = photocraft_doc::slices::resolve(&doc).len(); - if !doc.slices.is_empty() { + f.insert("__preset".into(), json!(preset)); + } + ui.add_space(4.0); + dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); + ui.add_space(4.0); + let fmt = s(f, "format", "jpeg"); + match fmt.as_str() { + "gif" | "png8" => { + dropdown_str( + ui, + "web-palette", + f, + "palette", + &[ + ("perceptual", tl!("Perceptual")), + ("selective", tl!("Selective")), + ("adaptive", tl!("Adaptive")), + ("restrictive", tl!("Restrictive (Web)")), + ("exact", tl!("Exact")), + ], + 150.0, + ); + number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], + 150.0, + ); + number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); + } + "jpeg" => { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + check(ui, f, "progressive", tl!("Progressive"), false); + check(ui, f, "optimized", tl!("Optimized"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } + "wbmp" => { + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], + 150.0, + ); + } + _ => { + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + } + } + if fmt != "png24" || !b(f, "transparency", true) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); + let mut m = s(f, "matte", "#ffffff"); + if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { + f.insert("matte".into(), json!(m)); + } + }); + } ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); - check(ui, f, "html", tl!("Save HTML and Images"), true); - dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); - } + check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); + dropdown_str( + ui, + "web-meta", + f, + "metadata", + &[ + ("none", tl!("None")), + ("copyright", tl!("Copyright")), + ("copyrightAndContact", tl!("Copyright and Contact Info")), + ("all", tl!("All")), + ], + 160.0, + ); + }); + if matches!(fmt.as_str(), "gif" | "png8") { + web_preview::table(ui, f); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); + number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); + let pct = n(f, "percent", 100.0) / 100.0; + ui.label( + egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) + .color(t.text_dim) + .size(11.5), + ); + let nslices = photocraft_doc::slices::resolve(&doc).len(); + if !doc.slices.is_empty() { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); + check(ui, f, "html", tl!("Save HTML and Images"), true); + dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); + } + }); }); }); } fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - let mut p = params(f); // Check while the control/input policy is still installed, before an asynchronous picker // can outlive the request. A browser download uses the interactive platform writer. - if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { - authorize(WEB_COMMAND, &p).map_err(|e| e.to_string())?; - } - if app.automation_input - && let Some(authorize) = app.services.automation_command.as_ref() - { - authorize(WEB_COMMAND, &p)?; + let authorize = |p: &Value| -> Result<(), String> { + if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { + authorize(WEB_COMMAND, p).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(WEB_COMMAND, p)?; + } + Ok(()) + }; + let mut p = params(f); + authorize(&p)?; + // Confirmation may follow a field edit without an intervening frame (Enter or automation). + let mut fields = f.clone(); + web_preview::prepare_table(app, &mut fields)?; + let prepared = params(&fields); + if prepared != p { + // The policy must also approve the actual palette-bearing export parameters. + authorize(&prepared)?; + p = prepared; } if p.get("path").is_some() || p.get("dir").is_some() { return save_for_web(app, p); @@ -964,6 +1014,118 @@ mod tests { } } + #[test] + fn save_for_web_authorizes_generated_palette_before_opening_a_picker() { + fn deny_palette(_: &str, p: &Value) -> photocraft_engine::Result<()> { + if p.get("colorTable").is_some() { Err(photocraft_engine::EngineError::Other("palette denied".into())) } else { Ok(()) } + } + for synthetic_input in [false, true] { + let (mut app, _) = app(); + let id = open_web(&mut app); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + fields.insert("format".into(), json!("gif")); + assert!(!fields.contains_key("colorTable")); + if synthetic_input { + app.automation_input = true; + app.services.automation_command = Some(Box::new(|cmd, p| deny_palette(cmd, p).map_err(|e| e.to_string()))); + } else { + app.session.authorize = Some(deny_palette); + } + app.services.file_dialog = Some(Box::new(|_, _, _| panic!("denied palette must not show a picker"))); + app.services.write = Some(Box::new(|_, _| panic!("denied palette must not write"))); + assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "palette denied"); + assert!(!app.file_dialog_open()); + } + } + + #[test] + fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { + let (mut app, _) = app(); + let id = open_web(&mut app); + let revision = app.session.active().unwrap().revision; + app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!("optimized")); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let pane = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let r = &h.state().ui.dialogs[0].fields["__previewRects"][0]; + egui::Rect::from_min_max( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::pos2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ) + }; + let before = pane(&h); + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap(); + let corner = rects[0].max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = corner + egui::vec2(180.0, 100.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + let after = pane(&h); + assert!(after.width() > before.width() + 100.0, "before {before:?}, after {after:?}, corner {corner:?}"); + assert!(after.height() > before.height() + 50.0); + // Zoom in enough that the small fixture can pan, then drag the real pane. + h.state_mut().ui.dialogs[0].fields.insert("__zoom".into(), json!(16.0)); + h.run_steps(3); + let at = pane(&h).center(); + h.event(egui::Event::PointerMoved(at)); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = at + egui::vec2(35.0, 20.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert_ne!(h.state().ui.dialogs[0].fields["__center"], json!([0.5, 0.5])); + assert_eq!(h.state().session.active().unwrap().revision, revision); + // Enter while editing a swatch must not export and close the parent dialog. + h.state_mut().ui.dialogs[0].fields.insert("format".into(), json!("gif")); + h.run_steps(4); + use egui_kittest::kittest::Queryable; + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1); + } + + #[test] + fn palette_draft_survives_navigation_and_resets_when_colors_change() { + let (app, _) = app(); + let mut f = serde_json::from_value::>(json!({"format": "gif", "colors": 16})).unwrap(); + web_preview::prepare_table(&app, &mut f).unwrap(); + let table = f["colorTable"].clone(); + f.insert("colorTableEdits".into(), json!({"0": "#12ab34"})); + f.insert("__zoom".into(), json!(2.0)); + f.insert("__center".into(), json!([0.25, 0.75])); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTable"], table); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34"); + f.insert("metadata".into(), json!("none")); + f.insert("interlaced".into(), json!(true)); + f.insert("dither".into(), json!("pattern")); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34", "non-palette settings must retain edits"); + f.insert("colors".into(), json!(8)); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert!(!f.contains_key("colorTableEdits")); + assert!(f["colorTable"].as_array().unwrap().len() <= 8); + } + #[test] fn print_dialog_and_toggles() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs new file mode 100644 index 0000000000..a9ee7a933c --- /dev/null +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -0,0 +1,323 @@ +//! Save for Web navigation and indexed palette UI. Draft state is serialized in dialog fields. +use super::*; + +/// Export dimensions, using the same percent/width/height precedence as web_document. +pub fn dimensions(doc: &photocraft_doc::Document, p: &Value) -> Result<(f64, f64), String> { + let (w0, h0) = (f64::from(doc.size.width).max(1.0), f64::from(doc.size.height).max(1.0)); + let positive = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let (w, h) = match (positive("percent"), positive("width"), positive("height")) { + (Some(pct), _, _) => (w0 * pct / 100.0, h0 * pct / 100.0), + (_, Some(w), Some(h)) => (w, h), + (_, Some(w), None) => (w, h0 * w / w0), + (_, None, Some(h)) => (w0 * h / h0, h), + _ => (w0, h0), + }; + if !w.is_finite() || !h.is_finite() || w > 300_000.0 || h > 300_000.0 { + return Err("export dimensions exceed the 300,000 px per-side limit".into()); + } + Ok((w.round().max(1.0), h.round().max(1.0))) +} + +pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result<(), String> { + if !matches!(s(f, "format", "jpeg").as_str(), "gif" | "png8") { + return Ok(()); + } + let st = app.session.active().ok_or("no document")?; + let mut source = params(f); + let Some(m) = source.as_object_mut() else { return Err("invalid web settings".into()) }; + // Only palette-affecting options invalidate index-based edits. Metadata, interlacing, + // dithering and output paths must not discard the user's replacement colours. + m.retain(|k, _| { + matches!( + k.as_str(), + "preset" | "format" | "palette" | "colors" | "transparency" | "matte" | "webSnap" | "convertToSrgb" | "width" | "height" | "percent" | "resample" + ) + }); + let signature = format!("{}|{}|{}", st.doc.id.0, st.revision, source); + if f.get("__tableSignature").and_then(Value::as_str) == Some(&signature) { + return Ok(()); + } + // A regenerated palette invalidates index-based edits. Explicit tables supplied through + // ui.dialog.set remain usable until a palette/size setting changes. + if f.contains_key("__tableSignature") { + f.remove("colorTable"); + f.remove("colorTableEdits"); + f.remove("__selectedColor"); + } + if !f.contains_key("colorTable") { + let settings = WebSettings::from_params(&source, "preview").map_err(|e| e.to_string())?; + // Bound palette preparation as well as the displayed image. The fixed table is then + // passed unchanged to full-resolution export, so the swatches are the file's colours. + let (w, h) = dimensions(&st.doc, &source)?; + let scale = (2048.0 / w.max(h)).min(1.0); + let mut proxy = source.clone(); + if let Some(m) = proxy.as_object_mut() { + m.remove("percent"); + m.insert("width".into(), json!((w * scale).round().max(1.0))); + m.insert("height".into(), json!((h * scale).round().max(1.0))); + m.insert("resample".into(), json!("bilinear")); + } + let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; + f.insert("__paletteApproximate".into(), json!(scale < 1.0)); + let buf = photocraft_compose::flatten(&doc); + let mut optimized = + web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + // Tiny transparent features may disappear in the proxy; keep room for their index. + if settings.transparency { + optimized.color_table.truncate(255); + } + f.insert("colorTable".into(), json!(optimized.color_table.iter().map(|c| web_cmds::rgb_hex(*c)).collect::>())); + f.insert("__transparentIndex".into(), json!(optimized.transparent_index)); + } + f.insert("__tableSignature".into(), json!(signature)); + Ok(()) +} + +pub fn table(ui: &mut egui::Ui, f: &mut Map) { + let p = params(f); + let Ok(settings) = WebSettings::from_params(&p, "preview") else { return }; + let Some(table) = settings.color_table else { return }; + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Color Table…").trim_end_matches('…')).font(crate::theme::semibold(12.0))); + ui.scope(|ui| { + ui.spacing_mut().interact_size = egui::vec2(14.0, 14.0); + ui.spacing_mut().item_spacing = egui::vec2(1.0, 1.0); + egui::Grid::new("web-color-table").spacing(egui::vec2(1.0, 1.0)).show(ui, |ui| { + for (i, source) in table.iter().enumerate() { + let mut color = settings.color_table_edits.get(&i).copied().unwrap_or(*source); + let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color)).inner; + response.clone().on_hover_text(format!("Index {i}: {}", web_cmds::rgb_hex(color))); + if response.clicked() { + f.insert("__selectedColor".into(), json!(i)); + } + if f.get("__selectedColor").and_then(Value::as_u64) == Some(i as u64) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(response.rect, t.radius_sm, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if response.changed() { + let edits = f.entry("colorTableEdits").or_insert_with(|| json!({})); + if let Some(edits) = edits.as_object_mut() { + edits.insert(i.to_string(), json!(web_cmds::rgb_hex(color))); + } + } + if i % 16 == 15 { + ui.end_row(); + } + } + if f.get("__transparentIndex").and_then(Value::as_u64).is_some() { + let (rect, response) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::checker(ui.painter(), rect, 4.0); + response.on_hover_text(tl!("Transparency")); + } + }); + }); + ui.horizontal(|ui| { + ui.weak(format!("{}: {}", tl!("Colors"), table.len())); + if ui.small_button(tl!("Reset")).clicked() { + f.remove("colorTableEdits"); + } + }); +} + +type EncodedPreview = (Vec, usize, Option, &'static str); + +/// Palette replacements reuse the cached index buffer; picker drags do not re-quantize or +/// re-encode the proxy. The GIF payload length is unchanged by RGB table replacements. +pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, px: &[[f32; 4]], w: u32, h: u32) -> Result { + let settings = WebSettings::from_params(p, "preview").map_err(|e| e.to_string())?; + let mut base = p.clone(); + if let Some(m) = base.as_object_mut() { + m.remove("colorTableEdits"); + } + let signature = format!("{signature}|{base}"); + let key = key.with("indexed-source"); + let cached: Option<(String, Arc)> = ui.data(|d| d.get_temp(key)); + let encoded = if let Some((_, encoded)) = cached.filter(|(sig, _)| sig == &signature) { + encoded + } else { + let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; + let encoded = Arc::new( + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + .map_err(|e| e.to_string())?, + ); + ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); + encoded + }; + let rgba = if settings.color_table_edits.is_empty() || !settings.format.indexed() { + encoded.preview.clone() + } else { + encoded + .indices + .iter() + .flat_map(|index| { + let index = usize::from(*index); + let color = settings.color_table_edits.get(&index).copied().or_else(|| encoded.color_table.get(index).copied()).unwrap_or([0; 3]); + [color[0], color[1], color[2], if Some(index) == encoded.transparent_index { 0 } else { 255 }] + }) + .collect() + }; + Ok((rgba, encoded.bytes.len(), encoded.colors, encoded.ext)) +} + +#[derive(Clone, Copy, Debug)] +struct Navigation { + zoom: Option, + center: egui::Vec2, +} +impl Navigation { + fn read(f: &Map) -> Self { + let zoom = f.get("__zoom").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0).map(|z| (z as f32).clamp(0.0001, 32.0)); + let center = f + .get("__center") + .and_then(Value::as_array) + .and_then(|a| Some(egui::vec2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))) + .filter(|c| c.is_finite()) + .unwrap_or(egui::vec2(0.5, 0.5)); + Self { zoom, center } + } + fn write(self, f: &mut Map) { + f.insert("__zoom".into(), json!(self.zoom)); + f.insert("__center".into(), json!([self.center.x, self.center.y])); + } + fn scale(self, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) -> f32 { + self.zoom.map_or((viewport.x / source.x).min(viewport.y / source.y), |z| z / ppp) + } + fn clamp(&mut self, viewport: egui::Vec2, image: egui::Vec2) { + let half = (viewport / image * 0.5).min(egui::vec2(0.5, 0.5)); + self.center = self.center.clamp(half, egui::Vec2::splat(1.0) - half); + } + fn zoom_at(&mut self, factor: f32, at: egui::Vec2, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) { + if !factor.is_finite() || factor <= 0.0 { + return; + } + let old = self.scale(viewport, source, ppp); + self.zoom = Some((old * ppp * factor).clamp(0.0001, 32.0)); + let new = self.scale(viewport, source, ppp); + self.center += at / source * (1.0 / old - 1.0 / new); + self.clamp(viewport, source * new); + } +} + +pub fn controls(ui: &mut egui::Ui, f: &mut Map) { + let mut nav = Navigation::read(f); + let effective = n(f, "__effectiveZoom", 1.0) as f32; + ui.horizontal(|ui| { + if ui.small_button(tl!("Fit")).clicked() { + nav.zoom = None; + nav.center = egui::vec2(0.5, 0.5); + } + if ui.small_button("−").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) / 1.25).max(0.0001)); + } + if ui.small_button("+").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) * 1.25).min(32.0)); + } + let mut percent = nav.zoom.unwrap_or(effective) * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).changed() { + nav.zoom = Some(percent / 100.0); + } + if ui.small_button(tl!("100%")).clicked() { + nav.zoom = Some(1.0); + } + ui.add(egui::Label::new(egui::RichText::new(tl!("Drag to pan · Scroll to zoom · Double-click to fit")).weak()).truncate()); + }); + nav.write(f); +} + +pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex: &egui::TextureHandle, source: egui::Vec2, f: &mut Map) { + let mut nav = Navigation::read(f); + let ppp = ui.ctx().pixels_per_point(); + if response.double_clicked() { + nav.zoom = None; + nav.center = egui::Vec2::splat(0.5); + } + let mut scale = nav.scale(rect.size(), source, ppp); + if response.dragged() { + nav.zoom = Some(scale * ppp); + nav.center -= response.drag_delta() / (source * scale); + } + if response.hovered() { + let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta())); + let factor = if pinch != 1.0 { pinch } else { (scroll * 0.005).exp() }; + if factor != 1.0 { + let at = response.hover_pos().unwrap_or(rect.center()) - rect.center(); + nav.zoom_at(factor, at, rect.size(), source, ppp); + scale = nav.scale(rect.size(), source, ppp); + ui.ctx().request_repaint(); + } + } + nav.clamp(rect.size(), source * scale); + let image = egui::Rect::from_min_size(rect.center() - nav.center * source * scale, source * scale); + ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).image( + tex.id(), + image, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + // Fit follows resizing; explicit zoom/pan shares the same normalized centre in every pane. + if nav.zoom.is_some() { + f.insert("__effectiveZoom".into(), json!(nav.zoom)); + } else { + f.insert("__effectiveZoom".into(), json!(scale * ppp)); + } + nav.write(f); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn anchored_zoom_keeps_pixel_under_pointer_and_pan_is_bounded() { + let mut nav = Navigation { zoom: Some(1.0), center: egui::Vec2::splat(0.5) }; + let viewport = egui::vec2(200.0, 100.0); + let source = egui::vec2(800.0, 400.0); + let at = egui::vec2(30.0, 20.0); + let before = nav.center + at / source; + nav.zoom_at(2.0, at, viewport, source, 1.0); + let after = nav.center + at / (source * 2.0); + assert!((before - after).length() < 0.00001); + nav.center = egui::vec2(-5.0, 50.0); + nav.clamp(viewport, source * 2.0); + assert_eq!(nav.center, egui::vec2(0.0625, 0.9375)); + nav.zoom = None; + assert_eq!(nav.scale(viewport, source, 1.0), 0.25); + assert_eq!(nav.scale(viewport * 2.0, source, 1.0), 0.5); + } + + #[test] + fn palette_picker_reuses_indices_and_updates_preview_pixels() { + let key = egui::Id::new("test-web-preview"); + let p = json!({"format": "gif", "colorTable": ["#ff0000", "#0000ff"], "dither": "none"}); + let mut h = egui_kittest::Harness::builder().build_ui_state( + move |ui, state: &mut (Value, Vec)| { + state.1 = optimized(ui, key, "fixture", &state.0, &[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]], 2, 1).unwrap().0; + }, + (p, Vec::new()), + ); + let cached = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc)>(key.with("indexed-source"))).unwrap().1; + let before = cached(&h.ctx); + assert_eq!(h.state().1, [255, 0, 0, 255, 0, 0, 255, 255]); + h.state_mut().0["colorTableEdits"] = json!({"0": "#00ff00"}); + h.run_steps(3); + assert!(Arc::ptr_eq(&before, &cached(&h.ctx)), "picker drag must not re-encode"); + assert_eq!(h.state().1, [0, 255, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn absurd_dimensions_and_navigation_fields_are_safe() { + let doc = + photocraft_doc::Document::new("fixture", photocraft_geom::Size::new(100, 50), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + assert!(dimensions(&doc, &json!({"percent": 1e308})).is_err()); + assert!(dimensions(&doc, &json!({"width": 1e300})).is_err()); + let f = serde_json::from_value::>(json!({"__zoom": 1e300, "__center": [1e300, -1e300]})).unwrap(); + let nav = Navigation::read(&f); + assert_eq!(nav.zoom, Some(32.0)); + assert_eq!(nav.center, egui::Vec2::splat(0.5)); + let mut nav = nav; + for factor in [f32::NAN, f32::INFINITY, 0.0, -1.0] { + nav.zoom_at(factor, egui::Vec2::ZERO, egui::vec2(200.0, 100.0), egui::vec2(400.0, 200.0), 1.0); + assert_eq!(nav.zoom, Some(32.0)); + assert!(nav.center.is_finite()); + } + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36fc380769..89fa3da9f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2323,6 +2323,7 @@ cameraRaw Color Barvy Click to pick · Esc to cancel Kliknutí vybere · Esc zruší Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení Scroll to zoom Rolováním přibližujte + Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte Show/Hide All Panels Zobrazit/skrýt všechny panely Show/Hide Panels Zobrazit/skrýt panely Could not open Nelze otevřít diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dc366b4553..fde840b1ed 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2360,6 +2360,7 @@ Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen Scroll to zoom Zum Zoomen scrollen + Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen Show/Hide All Panels Alle Bedienfelder ein-/ausblenden Show/Hide Panels Bedienfelder ein-/ausblenden Could not open Öffnen nicht möglich diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3f693d543f..a3da64e9dc 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3503,6 +3503,7 @@ fontWeight Thin Πολύ λεπτά Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση + Drag to pan · Scroll to zoom · Double-click to fit Σύρετε για μετακίνηση · Κυλήστε για μεγέθυνση · Διπλό κλικ για προσαρμογή # Saved New Document presets (#1516) Preset Name Όνομα προεπιλογής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f001c478b..96ecc18d39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2391,6 +2391,7 @@ cameraRaw Amount Cantidad Click to pick · Esc to cancel Clic para elegir · Esc para cancelar Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar Scroll to zoom Desplace para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar Show/Hide All Panels Mostrar/ocultar todos los paneles Show/Hide Panels Mostrar/ocultar paneles Could not open No se pudo abrir diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4dd927500c..3f9ec9fb37 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2745,6 +2745,7 @@ cameraRaw Blending Fusion Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler Scroll to zoom Défiler pour zoomer + Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster Show/Hide All Panels Afficher/masquer tous les panneaux Show/Hide Panels Afficher/masquer les panneaux Could not open Impossible d’ouvrir diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 151a997bfe..f37ef4d335 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3993,3 +3993,4 @@ cameraRaw Yellow Sárga Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet + Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ea51c03775..18d2c776f8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2732,6 +2732,7 @@ cameraRaw Blending Pembauran Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan Scroll to zoom Gulir untuk memperbesar + Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel Show/Hide Panels Tampilkan/Sembunyikan Panel Could not open Tidak dapat membuka diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 060ac72fc6..de47f7bcaa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2395,6 +2395,7 @@ cameraRaw Amount Quantità Click to pick · Esc to cancel Clic per scegliere · Esc per annullare Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare Scroll to zoom Scorri per ingrandire + Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare Show/Hide All Panels Mostra/nascondi tutti i pannelli Show/Hide Panels Mostra/nascondi pannelli Could not open Impossibile aprire diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 40749c2d0b..f2d3bd6694 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2295,6 +2295,7 @@ cameraRaw Color カラー Click to pick · Esc to cancel クリックで選択 · Escでキャンセル Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル Scroll to zoom スクロールしてズーム + Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Show/Hide All Panels すべてのパネルを表示/非表示 Show/Hide Panels パネルを表示/非表示 Could not open 開けませんでした diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a93a20be59..84f30fc19c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2908,6 +2908,7 @@ cameraRaw Color 색상 Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소 Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소 Scroll to zoom 스크롤하여 확대/축소 + Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기 Show/Hide All Panels 모든 패널 표시/숨기기 Show/Hide Panels 패널 표시/숨기기 Could not open 열 수 없음 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fc5809033c..fde9f99743 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2331,6 +2331,7 @@ Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren Scroll to zoom Scrollen om te zoomen + Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken Show/Hide All Panels Alle panelen tonen/verbergen Show/Hide Panels Panelen tonen/verbergen Could not open Kan niet openen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d3ecd9433..29dfcef28d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3335,6 +3335,7 @@ cameraRaw Roughness Chropowatość Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować Scroll to zoom Przewijaj, aby zmieniać powiększenie + Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować Open in camera raw Otwórz w Camera Raw System title bar Systemowy pasek tytułu More documents Więcej dokumentów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26e9fd07e..a68ba566ff 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2750,6 +2750,7 @@ cameraRaw Whites Brancos Click to pick · Esc to cancel Clique para escolher · Esc para cancelar Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar Scroll to zoom Role para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar Show/Hide All Panels Mostrar/ocultar todos os painéis Show/Hide Panels Mostrar/ocultar painéis Could not open Não foi possível abrir diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e57d0686b9..e6caf04cb9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2592,6 +2592,7 @@ cameraRaw Roughness Неровность Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены Scroll to zoom Колесо — изменить масштаб + Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать Show/Hide All Panels Показать/скрыть все панели Show/Hide Panels Показать/скрыть панели Could not open Не удалось открыть diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3f552460ca..d944622ec2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3805,3 +3805,4 @@ cameraData Lens Lens Action playback stopped Eylem oynatma durduruldu Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor @plural {n} action|{n} actions {n} eylem + Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f7feba19a7..bd93075fa2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2564,6 +2564,7 @@ cameraRaw Roughness Шорсткість Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування Scroll to zoom Прокрутіть для масштабування + Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити Show/Hide All Panels Показати/приховати всі панелі Show/Hide Panels Показати/приховати панелі Could not open Не вдалося відкрити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 890533bab2..ea93bc326c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2038,6 +2038,7 @@ layers Fill: Độ tô: Sample Size: Kích thước mẫu: Save As Preset Lưu thành thiết lập sẵn Scroll to zoom Cuộn để thu phóng + Drag to pan · Scroll to zoom · Double-click to fit Kéo để xem toàn cảnh · Cuộn để thu phóng · Bấm đúp để vừa khung Selection Preview: Xem trước vùng chọn: Set additional Crop options Đặt tùy chọn Cắt bổ sung Set Black Point Đặt điểm đen diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f13bc3bfb..9e66946f4f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2330,6 +2330,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 单击选取 · Esc 取消 Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消 Scroll to zoom 滚动以缩放 + Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口 Show/Hide All Panels 显示/隐藏所有面板 Show/Hide Panels 显示/隐藏面板 Could not open 无法打开 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b72aa4509b..b7685958e3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2300,6 +2300,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 按一下選取 · Esc 取消 Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消 Scroll to zoom 捲動以縮放 + Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗 Show/Hide All Panels 顯示/隱藏所有面板 Show/Hide Panels 顯示/隱藏面板 Could not open 無法開啟 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f23745efdb..cd4df4fbd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,6 +64,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). + - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows From 3e3a948c0bf3a18adbfdd1565eb2a54db37fdd08 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 12 Oct 2026 17:53:09 -0700 Subject: [PATCH 051/223] Tests: GPU startup marker tests wait while another test's child process holds the lock (#4251) returned_non_graphics_errors_do_not_trigger_backend_recovery failed at random in full `cargo test` runs: "gpu-error: ...", left: false, right: true. Its gpu-error case drops the sentinel, which leaves the marker file in place, and calls `Sentinel::begin` again for the same directory. Tests in monitor_profile.rs spawn processes in the same binary. A child process started while the marker is open gets a copy of its file descriptor, and the marker stays locked through that copy until the child's exec closes it. When `begin` runs in that window, it returns `Previous::Busy` instead of the crashed marker, and the assertion fails. sentinel_detects_a_crash_and_clears_on_success has retried `begin` for this since 3b7870be (#289). The retry moves into a test helper, `begin_when_unlocked`, which also runs where returned_non_graphics_errors_do_not_trigger_backend_recovery and a_failed_cpu_start_does_not_pin_later_starts start again after dropping or finishing their sentinel. In a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the sentinel, which removes the marker, so the helper's first `begin` finds the lock free; the helper covers that test's branch for a plan that keeps the marker. A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes the marker's lock through a second handle after the sentinel is dropped, releases it from a thread after 50 ms, and checks that the helper returns the crashed marker. It fails when the helper calls `begin` only once. --- apps/photocraft/src/gpu_startup.rs | 54 +++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 4a612d3a0e..bef5bb41f6 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -672,7 +672,7 @@ mod tests { } else { s.finish(); } - let (prev, _s) = Sentinel::begin(&dir); + let (prev, _s) = begin_when_unlocked(&dir); assert_eq!(prev, Previous::Clean); assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto); let _ = std::fs::remove_dir_all(&dir); @@ -684,6 +684,22 @@ mod tests { d } + /// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns + /// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child + /// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test + /// drops its sentinel. + fn begin_when_unlocked(dir: &Path) -> (Previous, Option) { + let (mut prev, mut s) = Sentinel::begin(dir); + for _ in 0..50 { + if prev != Previous::Busy { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + (prev, s) = Sentinel::begin(dir); + } + (prev, s) + } + #[test] fn sentinel_detects_a_crash_and_clears_on_success() { let dir = temp_dir("cycle"); @@ -699,16 +715,7 @@ mod tests { assert!(none.is_none()); // The process dies in the driver: the lock goes, the marker stays. drop(s); - // Another test in this binary spawns a process: between its fork and exec the child briefly - // shares this file's flock, so `begin` can see the lock as busy. Retry for a moment. - let (mut prev, mut s) = Sentinel::begin(&dir); - for _ in 0..50 { - if prev != Previous::Busy { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - (prev, s) = Sentinel::begin(&dir); - } + let (prev, s) = begin_when_unlocked(&dir); let m = prev.crashed().cloned().expect("crash detected"); assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan))); // This start succeeds: the next one finds nothing. @@ -721,6 +728,29 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn begin_when_unlocked_waits_for_a_briefly_held_lock() { + let dir = temp_dir("shared-lock"); + let (_, s) = Sentinel::begin(&dir); + let mut s = s.expect("sentinel"); + s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap(); + // After the start dies, a second handle on the marker holds the lock for 50 ms, as a + // spawned child's copy of the descriptor does until the child calls exec. + let held = File::open(dir.join(MARKER_FILE)).unwrap(); + drop(s); + held.lock().unwrap(); + assert_eq!(Sentinel::begin(&dir).0, Previous::Busy); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + held.unlock().unwrap(); + }); + let (prev, s) = begin_when_unlocked(&dir); + release.join().unwrap(); + assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan")); + s.expect("sentinel").finish(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn returned_non_graphics_errors_do_not_trigger_backend_recovery() { let cases = [ @@ -738,7 +768,7 @@ mod tests { } else { s.finish(); } - let (previous, s) = Sentinel::begin(&dir); + let (previous, s) = begin_when_unlocked(&dir); assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}"); if !keep { assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan); From 8145bea70f4f8684bef3ebcfa46d77ecbf23c3bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:53:33 +0200 Subject: [PATCH 052/223] Control: a transport that gives up knows whether the command was dispatched (#3662) (#4256) * Control: a timeout says whether the command ran (#3662) A request still queued when its deadline passes is dropped before dispatch, but it answered with the same text as the transport giving up on a command that may still apply. The two now read differently: "timeout: not run ... safe to re-send" and "timeout: no reply in 60s ... may still apply", both keeping the `timeout` prefix machines match on. * Control: a transport that gives up knows whether the command was dispatched (#3662) The transport and the UI thread share the reply deadline, and the transport's wait runs out first, so the UI thread's "not run" reply almost never reached a client. A request now carries a dispatch flag settled once between them: the UI thread claims it before running, the transport abandons it when it stops waiting. The transport then replies "not run" for a request that will never run, and "may still apply" only for one that was dispatched; a request given up on is never run. --- apps/photocraft/src/control_server.rs | 5 +- crates/ui-egui/src/control.rs | 97 +++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 +- docs/control-protocol.md | 2 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index b1282419d8..258d89e1ec 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -12,7 +12,7 @@ use photocraft_automation::security::{ ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; -use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error}; +use photocraft_ui_egui::control::REPLY_DEADLINE; use serde_json::{Value, json}; pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver { @@ -93,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: let deadline = Instant::now() + REPLY_DEADLINE; let (mut req, rrx) = ControlRequest::new(method, params); req.deadline = Some(deadline); + let dispatch = req.dispatch.clone(); if tx.send(req).is_err() { break; } ctx.request_repaint(); let mut r = - rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()})); + rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()})); if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d0ef48f11..fb1f652576 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -46,12 +46,22 @@ pub type ControlResponse = Value; /// The control server's per-request reply deadline. pub const REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); -/// The error both timeout sites return when a request's [`REPLY_DEADLINE`] passes: the -/// transport thread waiting for a reply, and the UI thread rejecting a request it is only -/// now draining. Built from the same constant, so the text cannot drift from the deadline. +/// The error the transport thread returns when a dispatched command gave no reply within +/// [`REPLY_DEADLINE`]. It warns that the command may still apply: re-sending blindly could apply +/// an edit twice (#3662). Built from the deadline constant, so the text cannot drift from it. pub fn timeout_error() -> String { format!( - "timeout: no reply in {}s — the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + "timeout: no reply in {}s — the command may still be running and may still apply, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + REPLY_DEADLINE.as_secs() + ) +} + +/// The error for a request still queued when its deadline passed, from the UI thread or the +/// transport (see [`Dispatch`]). It was dropped before dispatch, so re-sending it is safe (#3662). Same `timeout` prefix as +/// [`timeout_error`], which machines match on. +pub fn expired_error() -> String { + format!( + "timeout: not run — the request was still queued when its {}s deadline passed, so it was dropped before dispatch and is safe to re-send; the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", REPLY_DEADLINE.as_secs() ) } @@ -62,12 +72,44 @@ pub struct ControlRequest { pub reply: Sender, /// Requests still queued at this instant are rejected; already dispatched work continues. pub deadline: Option, + /// Shared with the transport, so that when it stops waiting it knows whether the command ran. + pub dispatch: Dispatch, } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx, deadline: None }, rx) + (Self { method: method.into(), params, reply: tx, deadline: None, dispatch: Dispatch::default() }, rx) + } +} + +/// Whether a request was dispatched, settled once between the UI thread (about to run it) and +/// the transport (giving up on its reply). The transport's clock runs out no later than the UI +/// thread's, so without this it could only ever say the command may still apply (#3662). +#[derive(Clone, Default)] +pub struct Dispatch(std::sync::Arc); + +const QUEUED: u8 = 0; +const DISPATCHED: u8 = 1; +const ABANDONED: u8 = 2; + +impl Dispatch { + /// The UI thread claims the request to run it: false once the transport gave up on it. A + /// request the UI thread rejects as expired stays queued, which `give_up` reads as not run. + pub(crate) fn start(&self) -> bool { + self.settle(DISPATCHED) == DISPATCHED + } + + /// The transport stops waiting: the error to reply with, [`expired_error`] when the command + /// can no longer run and [`timeout_error`] when it was dispatched and may still apply. + pub fn give_up(&self) -> String { + if self.settle(ABANDONED) == DISPATCHED { timeout_error() } else { expired_error() } + } + + /// Moves a queued request to `to`; returns the state it ends in. + fn settle(&self, to: u8) -> u8 { + use std::sync::atomic::Ordering; + self.0.compare_exchange(QUEUED, to, Ordering::AcqRel, Ordering::Acquire).map_or_else(|state| state, |_| to) } } @@ -1565,11 +1607,54 @@ mod tests { retry.deadline = Some(Instant::now() + Duration::from_secs(60)); tx.send(retry).unwrap(); app.drain_control(&egui::Context::default()); - assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": super::timeout_error()})); + let expired_error = expired_reply.try_recv().unwrap()["error"].as_str().unwrap().to_string(); assert_eq!(retry_reply.try_recv().unwrap()["ok"], true); let after = app.session.active().unwrap(); assert_eq!(after.doc.layers.len(), layers + 1, "only the live retry adds a layer"); assert_eq!(after.history.past_len(), steps + 1, "the expired edit adds no undo step"); + // #3662: a caller must be able to tell this reply, safe to re-send, from the + // transport's, sent while the command may still apply. + assert!(expired_error.starts_with("timeout"), "machines match on the prefix: {expired_error}"); + assert!(expired_error.contains("not run"), "the expired request says it never ran: {expired_error}"); + let transport_error = super::timeout_error(); + assert!(!transport_error.contains("not run"), "the transport can't know it didn't run: {transport_error}"); + assert!(transport_error.contains("may still apply"), "the transport warns against a blind re-send: {transport_error}"); + } + + #[test] + fn a_transport_that_gives_up_learns_whether_the_command_can_still_run() { + // #3662: the transport's clock runs out no later than the UI thread's, so it is the + // one that answers; it must still tell a request that will never run from one that may. + use std::time::{Duration, Instant}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + let (tx, rx) = std::sync::mpsc::channel(); + app = app.with_control(rx); + let ctx = egui::Context::default(); + let request = |deadline: Instant| { + let (mut req, reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"})); + req.deadline = Some(deadline); + (req.dispatch.clone(), req, reply) + }; + + let (dispatch, queued, _) = request(Instant::now() + Duration::from_secs(60)); + tx.send(queued).unwrap(); + assert_eq!(dispatch.give_up(), super::expired_error(), "still queued: it will not run"); + app.drain_control(&ctx); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "a request given up on never runs"); + + let (dispatch, expired, _) = request(Instant::now() - Duration::from_secs(1)); + tx.send(expired).unwrap(); + app.drain_control(&ctx); + assert_eq!(dispatch.give_up(), super::expired_error(), "rejected by the UI thread: it did not run"); + + let (dispatch, live, reply) = request(Instant::now() + Duration::from_secs(60)); + tx.send(live).unwrap(); + app.drain_control(&ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); + assert_eq!(dispatch.give_up(), super::timeout_error(), "dispatched: it may still apply"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2bfa9e96fc..96547c0d2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1510,8 +1510,8 @@ impl PhotocraftApp { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); - if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - let _ = reply.send(serde_json::json!({"ok": false, "error": control::timeout_error()})); + if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) || !req.dispatch.start() { + let _ = reply.send(serde_json::json!({"ok": false, "error": control::expired_error()})); continue; } match control::handle(self, ctx, &req) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cd4df4fbd5..f698ee2a6c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ Each reply is one JSON line with the same `id`: {"id": 2, "ok": false, "error": "unknown tool `foo`"} ``` -The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. +The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The two timeouts read differently, both starting with `timeout`: a request that was never dispatched (still queued at the deadline) says `timeout: not run` and is safe to re-send, while `timeout: no reply in 60s` means the command was dispatched and may still be running and still apply, so check the document before re-sending it. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. The MCP server (`photocraft-cli mcp --bridge 127.0.0.1:`, crate `photocraft-automation`) wraps this same protocol. See [MCP bridge](#mcp-bridge) below. From 6f41b324a66d608d0e3ba604b8f52e79277f6668 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 08:53:49 +0800 Subject: [PATCH 053/223] Canvas: a tool drag past the edge auto-scrolls the view (#4183) (#4257) * Canvas: a tool drag past the edge auto-scrolls the view (#4183) Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed image now scrolls toward the pointer, as in Photoshop, and stops at the scroll limits. An axis where the image fits does not scroll, and an overscrolled view is not pulled back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW * Fix rotated canvas auto-scroll limits --------- Co-authored-by: Claude Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 25 ++- crates/ui-egui/src/canvas_autoscroll.rs | 223 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/scrollbars.rs | 30 ++- crates/ui-egui/tests/marquee_autoscroll.rs | 183 +++++++++++++++++ 5 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/canvas_autoscroll.rs create mode 100644 crates/ui-egui/tests/marquee_autoscroll.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38d1e33f69..d028918266 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -887,6 +887,14 @@ impl ViewXform { let u = self.unmap_vec(d); [(u.x + self.center[0]) as f64, (u.y + self.center[1]) as f64] } + /// On-screen bounds of the image, including camera rotation and pixel aspect correction. + pub fn image_bounds(self, size: [u32; 2]) -> Rect { + let (c, s) = self.sincos(); + let w = size[0] as f32 * self.sx() * self.zoom; + let h = size[1] as f32 * self.zoom; + let center = self.to_screen(size[0] as f32 / 2.0, size[1] as f32 / 2.0); + Rect::from_center_size(center, vec2(c.abs() * w + s.abs() * h, s.abs() * w + c.abs() * h)) + } pub fn doc_rect(&self, r: DRect) -> Rect { let pts = [ self.to_screen(r.x0 as f32, r.y0 as f32), @@ -2657,7 +2665,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: fill_view(&mut view, &doc, rect.size(), ppp, aspect); } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; @@ -3055,7 +3063,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view.center[1] -= d.y; } // Overscroll off: the glide stops at the pan limit, as the scrollbars do. - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.data_mut(|m| m.get_temp_mut_or_default::(flick_id).stop()); } } @@ -3678,6 +3686,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } + // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the + // drag's next move then extends into what scrolled in. + if primary + && app.drag.is_some() + && tool != Tool::Hand + && !middle + && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) + { + let step = crate::canvas_autoscroll::edge_step(p, rect, ui.input(|i| i.stable_dt)); + if crate::canvas_autoscroll::scroll(&mut view, step, xf) { + ctx.request_repaint(); + } + } // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; diff --git a/crates/ui-egui/src/canvas_autoscroll.rs b/crates/ui-egui/src/canvas_autoscroll.rs new file mode 100644 index 0000000000..2fafbdb105 --- /dev/null +++ b/crates/ui-egui/src/canvas_autoscroll.rs @@ -0,0 +1,223 @@ +//! Auto-scroll while a tool drag runs past the canvas edge (#4183). As in Photoshop, dragging a +//! marquee, lasso, brush stroke or crop off the visible part of a zoomed image scrolls the view +//! toward the pointer, so a selection can be larger than what is on screen. The view stops at the +//! scroll limits (it never auto-scrolls an image that already fits into empty pasteboard). + +use crate::canvas::ViewXform; +use crate::state::View; +use egui::{Pos2, Rect, Vec2, vec2}; + +/// Slowest and fastest auto-scroll, in egui points per second. +const MIN_SPEED: f32 = 120.0; +const MAX_SPEED: f32 = 2400.0; +/// Extra speed per point the pointer is past the edge. +const GAIN: f32 = 12.0; +/// A long frame moves at most this many seconds' worth, so a stall doesn't jump the view. +const MAX_DT: f32 = 0.05; + +/// How far, in screen points, to scroll this frame for a drag whose pointer is at `pointer` over a +/// canvas of `rect`. Zero while the pointer is inside the canvas. Each axis points toward the +/// pointer and speeds up the further past the edge it is. +pub fn edge_step(pointer: Pos2, rect: Rect, dt: f32) -> Vec2 { + if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || rect.width() <= 0.0 || rect.height() <= 0.0 { + return Vec2::ZERO; + } + let past = |p: f32, lo: f32, hi: f32| { + if p < lo { + p - lo + } else if p > hi { + p - hi + } else { + 0.0 + } + }; + let dt = dt.min(MAX_DT); + let axis = |d: f32| if d == 0.0 { 0.0 } else { d.signum() * (MIN_SPEED + GAIN * d.abs()).min(MAX_SPEED) * dt }; + vec2(axis(past(pointer.x, rect.left(), rect.right())), axis(past(pointer.y, rect.top(), rect.bottom()))) +} + +/// Move the camera by `step` in screen points, never past the image's on-screen bounds in the +/// direction of travel. A view the user already panned past a limit (Overscroll on) is not pulled +/// back: it just doesn't scroll further out. True when the view moved. +pub fn scroll(view: &mut View, step: Vec2, xf: ViewXform) -> bool { + if !step.is_finite() || step == Vec2::ZERO || !xf.zoom.is_finite() || xf.zoom <= 0.0 { + return false; + } + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() || !xf.rect.is_finite() { + return false; + } + // Compare screen axes before unrotating: a document axis that fits before rotation may + // be clipped afterwards, and a vertical screen drag must remain vertical at any angle. + let axis = |step: f32, lo: f32, hi: f32, v0: f32, v1: f32| { + if hi - lo <= v1 - v0 { + 0.0 + } else if step > 0.0 { + step.min((hi - v1).max(0.0)) + } else { + step.max((lo - v0).min(0.0)) + } + }; + let step = vec2( + axis(step.x, image.left(), image.right(), xf.rect.left(), xf.rect.right()), + axis(step.y, image.top(), image.bottom(), xf.rect.top(), xf.rect.bottom()), + ); + let d = xf.unmap_vec(step) / xf.zoom; + let moved = d.is_finite() && (d.x.abs() > 1e-3 || d.y.abs() > 1e-3); + if moved { + view.center[0] += d.x; + view.center[1] += d.y; + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::pos2; + + // Existing unrotated cases express their requested movement in document pixels. + fn scroll(view: &mut View, doc_step: Vec2, size: Vec2, ppp: f32) -> bool { + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, size), + zoom: view.zoom / ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + }; + super::scroll(view, doc_step * xf.zoom, xf) + } + + fn rect() -> Rect { + Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 350.0)) + } + + #[test] + fn inside_the_canvas_does_not_scroll() { + assert_eq!(edge_step(pos2(300.0, 200.0), rect(), 1.0 / 60.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(100.0, 350.0), rect(), 1.0 / 60.0), Vec2::ZERO, "the edge itself is inside"); + } + + #[test] + fn past_an_edge_scrolls_toward_the_pointer() { + let dt = 1.0 / 60.0; + let right = edge_step(pos2(520.0, 200.0), rect(), dt); + assert!(right.x > 0.0 && right.y == 0.0, "{right:?}"); + let left = edge_step(pos2(80.0, 200.0), rect(), dt); + assert!(left.x < 0.0 && left.y == 0.0, "{left:?}"); + let corner = edge_step(pos2(90.0, 360.0), rect(), dt); + assert!(corner.x < 0.0 && corner.y > 0.0, "{corner:?}"); + } + + #[test] + fn further_out_is_faster_up_to_a_cap() { + let dt = 1.0 / 60.0; + let near = edge_step(pos2(505.0, 200.0), rect(), dt).x; + let far = edge_step(pos2(600.0, 200.0), rect(), dt).x; + let very_far = edge_step(pos2(50_000.0, 200.0), rect(), dt).x; + assert!(near < far, "{near} < {far}"); + assert!((very_far - MAX_SPEED * dt).abs() < 1e-3, "capped: {very_far}"); + } + + #[test] + fn hostile_input_does_not_scroll() { + let dt = 1.0 / 60.0; + assert_eq!(edge_step(pos2(f32::NAN, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(f32::INFINITY, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), f32::NAN), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), -1.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), Rect::NOTHING, dt), Vec2::ZERO); + // A multi-second stall moves no more than one capped frame. + assert!(edge_step(pos2(600.0, 200.0), rect(), 10.0).x <= MAX_SPEED * MAX_DT + 1e-3); + } + + fn view(zoom: f32, center: [f32; 2]) -> View { + View { zoom, center, doc_size: [2000, 1000], ..Default::default() } + } + + #[test] + fn a_zoomed_view_scrolls_and_stops_at_the_limit() { + // 2000 × 1000 at 100 % in a 400 × 300 canvas: the centre can range over 200..1800 in x. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + let mut v = view(1.0, [1790.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1800.0, 500.0], "stops at the right limit"); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0), "and stays there"); + } + + #[test] + fn an_image_that_fits_does_not_scroll() { + let mut v = view(0.1, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + // Wider than the view but not taller: only x scrolls. + let mut v = view(0.25, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + } + + #[test] + fn an_overscrolled_view_is_not_pulled_back() { + // Already panned past the right limit (Overscroll on): scrolling further right does + // nothing, scrolling left moves normally. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1900.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1900.0, 500.0]); + assert!(scroll(&mut v, vec2(-50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1850.0, 500.0]); + } + + #[test] + fn non_finite_steps_are_ignored() { + let mut v = view(1.0, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(f32::NAN, 0.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + } + + #[test] + fn quarter_turn_limits_follow_the_displayed_image_with_hidpi_and_pixel_aspect() { + for rotation in [-90.0, 90.0] { + for flip in [false, true] { + let mut v = View { zoom: 2.0, center: [300.0, 600.0], doc_size: [600, 1200], rotation, ..Default::default() }; + let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation, aspect: 2.0 }; + assert!(super::scroll(&mut v, vec2(0.0, 10_000.0), xf)); + // At 2:1 pixel aspect the document's 600-pixel width is 1200 screen points + // tall after rotation. The 400-point viewport exposes 200 document pixels. + let want = if rotation > 0.0 { 500.0 } else { 100.0 }; + assert!((v.center[0] - want).abs() < 0.01, "{rotation}, flip={flip}: {:?}", v.center); + assert!((v.center[1] - 600.0).abs() < 0.01); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0), "the next frame preserves the auto-scroll limit"); + let xf = ViewXform { center: v.center, ..xf }; + assert!(!super::scroll(&mut v, vec2(0.0, 20.0), xf)); + v.center = [300.0, 600.0]; + let xf = ViewXform { center: v.center, ..xf }; + assert!(super::scroll(&mut v, vec2(10_000.0, 0.0), xf)); + let want_y = if (rotation > 0.0) == flip { 800.0 } else { 400.0 }; + assert!((v.center[1] - want_y).abs() < 0.01 && (v.center[0] - 300.0).abs() < 0.01, "mirrored horizontal limit: {:?}", v.center); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0)); + } + } + } + + #[test] + fn oblique_rotation_keeps_a_vertical_scroll_vertical_and_a_fitting_axis_still() { + for flip in [false, true] { + let mut v = View { zoom: 1.0, center: [300.0, 300.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(900.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation: v.rotation, aspect: 1.0 }; + // A 600-pixel square at 45 degrees is about 849 points across: it fits horizontally + // but is clipped vertically. Gating document axes would produce a diagonal drift. + assert!(!super::scroll(&mut v, vec2(30.0, 0.0), xf)); + assert!(super::scroll(&mut v, vec2(0.0, 30.0), xf)); + let after = ViewXform { center: v.center, ..xf }; + let displacement = after.to_screen(300.0, 300.0) - xf.to_screen(300.0, 300.0); + assert!(displacement.x.abs() < 0.01 && (displacement.y + 30.0).abs() < 0.01, "{displacement:?}"); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 1.0, 1.0)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96547c0d2d..a71d8134a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ mod camera_raw_preview_ui; mod camera_raw_scope_ui; pub mod camera_raw_ui; pub mod canvas; +pub mod canvas_autoscroll; mod canvas_popup; pub mod canvas_tool_menu; pub mod cas_transform; diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 2ab7cdeab6..e0867436c4 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -90,14 +90,40 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { /// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a /// display with `ppp` physical pixels per point. True when the view moved (then repaint). pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool { + clamp_view_with_aspect(view, size, ppp, 1.0) +} + +/// Clamp against the displayed image bounds, so rotated tool auto-scroll is not undone by +/// the next frame's Overscroll-off clamp. `size` is the unmodified screen-space canvas size. +pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspect: f32) -> bool { let [w, h] = view.doc_size; if w == 0 || h == 0 { return false; } let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)]; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let c = if view.rotation.abs() < 1e-8 { + [clamp_axis(view.center[0], w as f32, size.x / aspect, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)] + } else { + if !zoom.is_finite() || zoom <= 0.0 || !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return false; + } + let center = [ + if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 }, + if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 }, + ]; + let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect }; + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() { + return false; + } + let offset = xf.rect.center() - image.min; + let delta = vec2(clamp_axis(offset.x, image.width(), size.x, 1.0), clamp_axis(offset.y, image.height(), size.y, 1.0)) - offset; + let d = xf.unmap_vec(delta) / zoom; + [center[0] + d.x, center[1] + d.y] + }; // Ignore sub-pixel float noise so the clamp never keeps requesting frames. - let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; + let moved = !view.center[0].is_finite() || !view.center[1].is_finite() || (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; if moved { view.center = c; } diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/marquee_autoscroll.rs new file mode 100644 index 0000000000..2fc5772604 --- /dev/null +++ b/crates/ui-egui/tests/marquee_autoscroll.rs @@ -0,0 +1,183 @@ +//! #4183: dragging a Rectangular Marquee past the edge of a zoomed-in canvas scrolls the view, so +//! the selection can be larger than what is on screen (as in Photoshop). +//! +//! Before the fix the view stayed put and the selection stopped at the visible edge. The real app +//! runs offscreen on wgpu with real egui pointer events. Skips when no GPU adapter exists (like +//! `drag_preview_canvas.rs`). + +use egui::{Modifiers, PointerButton, Pos2}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + harness_with_size(egui::vec2(900.0, 640.0)) +} + +fn harness_with_size(size: egui::Vec2) -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(size).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 3000 × 2000 document at 100 %, centred, with `tool` active: far larger than the canvas. +fn setup(h: &mut Harness, tool: &str) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + app.run("file.new", json!({"width": 3000, "height": 2000, "background": "white"})).expect("new"); + app.sync_views(); + app.ui.extras.rulers = false; + } + control(h, "ui.set", json!({"tool": tool, "zoom": 1.0, "center": [1500, 1000]})); + h.run_steps(3); +} + +fn xf(h: &Harness) -> ViewXform { + ViewXform::active(h.state()).expect("an active document view") +} + +fn pointer_at(h: &mut Harness, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.step(); +} + +fn button_at(h: &mut Harness, p: Pos2, pressed: bool) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Selection bounds `[x0, y0, x1, y1]` in document pixels. +fn selection(h: &Harness) -> Option<[i32; 4]> { + let st = h.state().session.active()?; + let b = st.doc.selection.as_ref()?.content_bounds(); + (!b.is_empty()).then_some([b.x0, b.y0, b.x1, b.y1]) +} + +#[test] +fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let rect = start.rect; + // The document column at the canvas's right edge before the drag: the most a selection + // could reach without scrolling. + let visible_right = start.to_doc(egui::pos2(rect.right(), rect.center().y))[0]; + + let from = rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + let t = i as f32 / 6.0; + pointer_at(&mut h, from + egui::vec2((rect.right() - from.x - 10.0) * t, 60.0 * t)); + } + // Past the edge, then held still for a while: the view keeps scrolling. + let outside = egui::pos2(rect.right() + 40.0, from.y + 60.0); + pointer_at(&mut h, outside); + for _ in 0..30 { + h.step(); + } + let moved = xf(&h).center[0] - start.center[0]; + assert!(moved > 50.0, "the view scrolls right while the drag is held past the edge (moved {moved})"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 1e-3, "and only horizontally"); + + button_at(&mut h, outside, false); + h.run_steps(3); + let sel = selection(&h).expect("a selection"); + assert!(sel[2] as f64 > visible_right + 50.0, "the selection reaches past the column that was visible at the press: {sel:?} vs {visible_right}"); + + // Once released, the view no longer scrolls even with the pointer outside. + let after = xf(&h).center; + pointer_at(&mut h, outside); + h.run_steps(10); + assert_eq!(xf(&h).center, after); +} + +#[test] +fn a_drag_inside_the_canvas_does_not_scroll() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let from = start.rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + pointer_at(&mut h, from + egui::vec2(15.0 * i as f32, 10.0 * i as f32)); + } + for _ in 0..20 { + h.step(); + } + assert_eq!(xf(&h).center, start.center); + button_at(&mut h, from + egui::vec2(90.0, 60.0), false); +} + +#[test] +fn rotated_marquee_scrolls_on_the_axis_that_is_visibly_clipped() { + let Some(mut h) = harness_with_size(egui::vec2(1200.0, 640.0)) else { return }; + setup(&mut h, "rectMarquee"); + let rect = xf(&h).rect; + assert!(rect.width() > rect.height()); + // Fits horizontally before rotation, but at 90 degrees it overflows vertically. + let width = ((rect.width() + rect.height()) / 2.0) as u32; + let height = (rect.width() * 2.0) as u32; + for overscroll in [true, false] { + { + let app = h.state_mut(); + app.run("file.close", json!({"discard": true})).expect("close"); + app.run("file.new", json!({"width": width, "height": height, "background": "white"})).expect("new"); + app.run("prefs.set", json!({"path": "tools.overscroll", "value": overscroll})).expect("overscroll"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [width / 2, height / 2]})); + h.state_mut().ui.views[0].rotation = 90.0; + h.run_steps(3); + let start = xf(&h); + let from = start.rect.center(); + let outside = egui::pos2(from.x + 30.0, start.rect.bottom() + 40.0); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + pointer_at(&mut h, outside); + h.run_steps(30); + let moved = xf(&h).center[0] - start.center[0]; + if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).expect("fixture directory"); + h.render().expect("render").save(dir.join(format!("rotated-marquee-overscroll-{overscroll}.png"))).expect("save fixture"); + } + assert!(moved > 10.0, "rotated vertical overflow must scroll document x, overscroll={overscroll}, moved={moved}"); + let limit = width as f32 - start.rect.height() / (2.0 * start.zoom); + assert!((xf(&h).center[0] - limit).abs() < 0.01, "stops at the rotated image edge, overscroll={overscroll}"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 0.01); + button_at(&mut h, outside, false); + h.run_steps(3); + let after = xf(&h).center; + h.run_steps(10); + assert_eq!(xf(&h).center, after); + } +} From 94b60422891de71fb66663a92781f6a44bb11ec8 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:58 +0700 Subject: [PATCH 054/223] fix(linux): follow GNOME's light default when the portal has no preference (#4289) --- apps/photocraft/src/appearance.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index 8389ee83eb..efaf6ccdb5 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -14,7 +14,7 @@ fn portal_proxy() -> Option> { #[cfg(target_os = "linux")] fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; - decode(portal_code(value)?) + portal_code(value).and_then(decode).or_else(gtk_theme) } #[cfg(target_os = "linux")] @@ -39,8 +39,8 @@ fn decode(code: u32) -> Option { } } -/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by -/// absolute path, and is never repeated. +/// GNOME fallback when the portal has no preference. Read only on the appearance worker, +/// using an absolute path rather than the shell's PATH. #[cfg(target_os = "linux")] fn gtk_theme() -> Option { let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; @@ -55,7 +55,7 @@ fn gtk_theme() -> Option { fn parse_gtk_scheme(s: &str) -> Option { match s.trim().trim_matches('\'') { "prefer-dark" => Some(egui::Theme::Dark), - "prefer-light" => Some(egui::Theme::Light), + "prefer-light" | "default" => Some(egui::Theme::Light), _ => None, } } @@ -97,7 +97,7 @@ pub fn service() -> Option { if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { continue; } - publisher.publish(portal_code(changed).and_then(decode)); + publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme)); } }) } @@ -184,7 +184,10 @@ mod tests { assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); - assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + // GNOME's "default" appearance is light, even though the portal reports no preference. + assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None); } /// #2798: a first read that misses the start-up wait still wakes the idle UI. From 21b018c8a9440751e75cddcaa39f52488b1ac334 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:54:11 -0600 Subject: [PATCH 055/223] Brush Settings: Brush Tip Shape lines up with the other section names (#4282) (#4294) The section list indented every row without an enable box by the box's width, except the first, so Brush Tip Shape started in the checkbox column while Smoothing, the other box-less row, lined up with the names. Photoshop indents Brush Tip Shape too. Every name now starts in the same column. Test: section_names_line_up_whether_or_not_they_have_a_box checks the painted x of every section name (Brush Tip Shape was 20 px left before). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 4 +++- crates/ui-egui/src/brush_panel_tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index d018219238..8f51ca8572 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -295,7 +295,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting *flag = true; } x += 20.0; - } else if i > 0 { + } else { + // No box (Brush Tip Shape): the name still starts in the names' column, + // as in Photoshop (#4282). x += 20.0; } let color = if sel { t.text } else { t.text_dim }; diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 34dc0d9d4d..49ddc0063c 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -599,6 +599,31 @@ fn editing_the_options_bar_amount_reenables_smoothing() { assert_eq!(last_journal(h.state()).unwrap().1["brush"]["smoothing"]["enabled"], true); } +/// #4282: every name in the section list starts in the same column, Brush Tip Shape included, +/// whether or not its row has an enable box (Photoshop indents the rows without one). +#[test] +fn section_names_line_up_whether_or_not_they_have_a_box() { + fn text_x(shape: &egui::Shape, out: &mut Vec<(String, f32)>) { + match shape { + egui::Shape::Text(text) => out.push((text.galley.job.text.clone(), text.pos.x)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_x(s, out)), + _ => {} + } + } + // Shape Dynamics is shown, so its heading paints too; the list's copy is the leftmost. + let h = harness(0, 1); + let mut painted = Vec::new(); + for shape in &h.output().shapes { + text_x(&shape.shape, &mut painted); + } + let left = |name: &str| painted.iter().filter(|(t, _)| t == name).map(|(_, x)| *x).reduce(f32::min); + let columns: Vec<(&str, f32)> = SECTIONS.iter().map(|(name, _)| (*name, left(name).unwrap_or_else(|| panic!("{name} painted")))).collect(); + let (_, boxed) = columns[1]; + for (name, x) in &columns { + assert!((x - boxed).abs() < 0.5, "{name} starts at {x}, the boxed names at {boxed}: {columns:?}"); + } +} + #[test] fn preview_strip_re_renders_only_when_the_brush_changes() { let mut h = harness(0, 0); From e777690ac611b44fc64bff4cf9f2d950f98bd729 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:54:20 -0600 Subject: [PATCH 056/223] Properties: the Studio-layout themes show it for every layer (#4246) (#4299) In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita, Solarized) Properties is a floating card over the canvas. The card returned early unless the active layer was an adjustment or fill layer or a mask was targeted, so for a pixel layer, the Background, type or a group nothing was drawn, although Properties was on (the rail button showed it). The Pro theme's docked panel shows all of those. The card now renders the docked panel's body (properties_body), so both show the same content: the Document properties for the Background or no selection, layer, adjustment and mask properties. Its title bar, hide button, position and drag are unchanged. Tests: floating_properties_card_shows_document_properties_for_the_background and floating_properties_card_shows_pixel_layer_properties (Studio and Breeze Dark) failed before the fix. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/mask_props_ui_tests.rs | 40 ++++++++++++++++++++ crates/ui-egui/src/panels.rs | 46 ++--------------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index d02af4f257..1cde904682 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -213,3 +213,43 @@ fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { } } } + +/// A Studio-layout theme's floating Properties card, with no mask targeted. +fn card_harness(new_layer: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + if new_layer { + session.execute("layer.new.layer", json!({})).unwrap(); + } + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + properties_window(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn floating_properties_card_shows_document_properties_for_the_background() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(false, theme); + assert!(h.query_all_by_label("Document").next().is_some(), "{theme:?}: Document properties in the card"); + } +} + +#[test] +fn floating_properties_card_shows_pixel_layer_properties() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(true, theme); + assert!(h.query_all_by_label("Opacity").next().is_some(), "{theme:?}: layer controls in the card"); + assert!(h.query_all_by_label("Document").next().is_none()); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f0e0046711..e8f5803b21 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3072,7 +3072,8 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static // ----------------------------------------------------------------------------- properties -/// Floating Properties card anchored to the canvas' top-right corner. +/// Floating Properties card anchored to the canvas' top-right corner. It shows the same content as +/// the docked panel (`properties_body`): document, layer, adjustment and mask properties. pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.docking.is_some() { return; @@ -3081,14 +3082,7 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.properties || Tokens::get(ctx).pro { return; } - let Some(st) = app.session.active() else { return }; - let Some(id) = st.active_layer else { return }; - let doc = st.doc.clone(); - let Some(layer) = doc.layer(id) else { return }; - // Mask controls (pixel or vector) also belong here when their thumbnail is targeted. - let mask_target = crate::mask_props_ui::targeted(app, layer); - let vector_target = crate::mask_props_ui::vector_targeted(app, layer); - if !mask_target && !vector_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { + if app.session.active().is_none() { return; } let t = Tokens::get(ctx); @@ -3124,41 +3118,9 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } drag = grip.drag_delta(); ui.add_space(6.0); - let icon = match &layer.content { - LayerContent::Adjustment(_) => "sliders-horizontal", - LayerContent::Group(_) => "folder", - LayerContent::Fill(_) => "paint-bucket", - _ => "image", - }; - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover()); - ui.painter().rect_filled(r, t.radius_sm, t.field); - icons::paint(ui, r, icon, 17.0, t.icon); - ui.vertical(|ui| { - ui.label(RichText::new(&layer.name).font(theme::medium(13.0)).color(t.text)); - let kind = match &layer.content { - LayerContent::Adjustment(a) => crate::i18n::fmt(tl!("{name} Properties"), &[("name", tl!(a.label()))]), - // Whole phrases ("Type Layer"), as the Properties header translates them. - other => tl!(&format!("{} Layer", other.kind_name())).to_string(), - }; - ui.label(RichText::new(kind).small().color(t.text_faint)); - }); - }); - ui.add_space(8.0); widgets::hairline(ui); ui.add_space(8.0); - // Per-layer ids, so text still being typed for one layer can't commit to the next. - ui.push_id((doc.id, id, mask_target), |ui| { - if mask_target { - crate::mask_props_ui::properties(app, ui, layer); - } else if vector_target { - crate::mask_props_ui::vector_properties(app, ui, layer); - } else if let LayerContent::Adjustment(adj) = &layer.content { - adjustment_controls(app, ui, id, adj); - } else { - layer_controls(app, ui, layer); - } - }); + properties_body(app, ui); }); }); if drag != egui::Vec2::ZERO && canvas.is_positive() { From 315c9cc0bcbd0560c85a5e6e6265fc28248f1550 Mon Sep 17 00:00:00 2001 From: Lessa Date: Mon, 12 Oct 2026 21:55:28 -0300 Subject: [PATCH 057/223] Give numeric type controls translated accessible names (#4310) * Give numeric type controls translated accessible names * Find the size and leading row by accessible names in layout tests --- crates/ui-egui/src/props_layout_tests.rs | 8 +++-- crates/ui-egui/src/type_tool.rs | 10 ++++-- crates/ui-egui/src/type_tool_tests.rs | 41 ++++++++++++++++++++---- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 0b99569fe7..8f7fbc15f0 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -175,8 +175,12 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() { .map(|r| r.right()) .fold(f32::MIN, f32::max); // (Both rows have a unit suffix: compare with the size/leading row.) - let top = fields.iter().map(|r| r.top()).fold(f32::MAX, f32::min); - let lead = fields.iter().filter(|r| (r.top() - top).abs() < 0.5).map(|r| r.right()).fold(f32::MIN, f32::max); + // Variable-font axes can precede the size/leading row and have no unit suffix. + // Compare the intended point-valued controls, retaining the same edge tolerance. + let lead = ["Font size", "Leading (set to the font size × auto-leading when Auto)"] + .into_iter() + .map(|label| h.get_by_role_and_label(egui::accesskit::Role::SpinButton, label).rect().right()) + .fold(f32::MIN, f32::max); assert!((t_right - lead).abs() < 0.5, "{what}: Transform fields end at {t_right}, Character at {lead}"); // Quick Actions buttons span the panel too. let qa = h.get_by_label("Rasterize Type").rect(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 28d4d7c56b..4bfa31227c 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1733,8 +1733,14 @@ fn num_field(ui: &mut egui::Ui, label: &str, tip: &str, v: f32, range: std::ops: ui.spacing_mut().item_spacing.x = 4.0; let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, crate::theme::semibold(10.0), t.text_dim); - crate::widgets::scrub_label(ui, &resp.on_hover_text(tip)); - if crate::widgets::value_field(ui, &mut v, range, unit, width).changed() { + crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip))); + let response = crate::widgets::value_field(ui, &mut v, range, unit, width); + response.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), f64::from(v)); + info.label = Some(tl!(tip).to_owned()); + info + }); + if response.changed() { out = Some(v); } }); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index a6b045bc1b..25011745e2 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -14,6 +14,33 @@ use super::{layout, text_layer}; use crate::PhotocraftApp; use crate::canvas::ViewXform; +#[test] +fn numeric_type_fields_have_localized_accessible_names() { + let pt = crate::i18n::Lang::from_code("pt-br").unwrap(); + let mut h = Harness::builder().build_ui_state( + move |ui, value: &mut f32| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::i18n::with_language(pt, || { + if let Some(next) = super::num_field(ui, "tT", "Font size", *value, 1.0..=999.0, "pt", 80.0) { + *value = next; + } + }); + }, + 24.0, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("36".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), 36.0); +} + fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { make_harness(ppp, app, false) } @@ -1333,8 +1360,10 @@ mod multi_layer_formatting { h } - fn number(h: &mut Harness<'static, PhotocraftApp>, index: usize, value: &str) { - h.get_all_by_role(Role::SpinButton).nth(index).expect("numeric field").click(); + fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) { + // Variable-font axes add numeric fields before size/leading. Select the setting by + // its accessible name, so a font scan cannot shift this action onto another control. + h.get_by_role_and_label(Role::SpinButton, label).click(); h.run(); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text(value.into())); @@ -1409,16 +1438,16 @@ mod multi_layer_formatting { let raster = LayerId(app.run("layer.new.layer", json!({"name": "Selected raster"})).unwrap()["layer"].as_u64().unwrap()); selected(&mut app, &[ids[0], ids[1], raster]); let mut h = controls(app, true); - number(&mut h, 0, "60"); + number(&mut h, "Font size", "60"); // Differ from the automatic 72 pt leading so this is an actual property change. - number(&mut h, 1, "84"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); h.get_by_label("Faux Bold").click(); h.run(); h.get_by_label("Center text").click(); h.run(); h.get_by_label("Hyphenate").click(); h.run(); - number(&mut h, 6, "9"); + number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); assert_eq!(c.size_pt * scale, 60.0); @@ -1435,7 +1464,7 @@ mod multi_layer_formatting { let peer = text(h.state(), ids[1]).char_runs(); editing(h.state_mut(), ids[0]); h.run(); - number(&mut h, 0, "40"); + number(&mut h, "Font size", "40"); assert_eq!((size_at(h.state(), ids[0], 0), size_at(h.state(), ids[0], 1), size_at(h.state(), ids[0], 3)), (30.0, 20.0, 30.0)); assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } From 68a31b5a123b66584616b62a2f236c0a53acf1e3 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:55:38 -0600 Subject: [PATCH 058/223] Free Transform: unlinking W and H frees the handles too (#4174) (#4322) The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and the legacy preference are unchanged. The link state moves from egui temp data into UiState (transform_unlinked), next to the transform session. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/src/transform_tool.rs | 56 +++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 50f4b8b680..ff76da78c8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1055,6 +1055,10 @@ pub struct UiState { /// Free Transform session, if any. #[serde(default)] pub transform: Option, + /// The options bar's Maintain Aspect Ratio link is off: W/H change on their own, and handle + /// drags stretch freely (⇧ keeps the proportions), as in Photoshop (#4174). + #[serde(default)] + pub transform_unlinked: bool, /// Temporary symmetry-axis editing, exposed to automation but never restored from settings. #[serde(default, skip_deserializing)] pub symmetry_transform: Option, @@ -1255,6 +1259,7 @@ impl Default for UiState { type_transform_pivot: None, cam_transform: None, transform: None, + transform_unlinked: false, symmetry_transform: None, layer_picker: None, mask_target: false, diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 84d1031bb6..ecb19fff7b 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1287,6 +1287,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> 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()) && mode_allows(t.mode, g.hit) @@ -1294,12 +1295,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> { 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, mods)); + let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, 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, mods)); + apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); } } } @@ -1342,6 +1343,16 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> mods } +/// The options bar's Maintain Aspect Ratio link turned off: corner and edge drags stretch freely +/// and ⇧ keeps the proportions, as in Photoshop CC 2019 and later (#4174). The legacy preference +/// already works that way, so it is left alone there. +pub(crate) fn unlinked_mods(unlinked: bool, legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { + if unlinked && !legacy && !mods.command && matches!(hit, Hit::Corner(_) | Hit::Edge(_)) { + mods.shift = !mods.shift; + } + mods +} + /// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a /// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]). fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { @@ -2449,11 +2460,9 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; lbl(ui, "W:"); let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); - let link_id = egui::Id::new("transform-link"); - let mut link: bool = ui.data(|d| d.get_temp(link_id)).unwrap_or(true); + let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { - link = !link; - ui.data_mut(|d| d.insert_temp(link_id, link)); + app.ui.transform_unlinked = link; } lbl(ui, "H:"); let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); @@ -3843,6 +3852,41 @@ mod tests { } } + /// #4174: turning off the options bar's Maintain Aspect Ratio link frees the handles too, as in + /// Photoshop: a plain corner or edge drag stretches one way, ⇧ keeps the proportions. Linked + /// (the default) is unchanged, and so is the legacy preference. + #[test] + fn the_aspect_link_decides_whether_handle_drags_keep_proportions() { + let (none, shift) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT); + for (unlinked, legacy, mods, proportional) in + [(false, false, none, true), (true, false, none, false), (true, false, shift, true), (true, true, none, false)] + { + for at_edge in [false, true] { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.extras.snap = false; + app.ui.transform_unlinked = unlinked; + app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy); + begin(&mut app, &egui::Context::default()).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + let from = if at_edge { [q0[1][0], (q0[1][1] + q0[2][1]) / 2.0] } else { q0[2] }; + press_drag(&mut app, from, [from[0] + 16.0, from[1]], mods); + let q = app.ui.transform.as_ref().unwrap().quad; + let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]); + let case = format!("unlinked {unlinked}, legacy {legacy}, shift {}, edge {at_edge}: {w} × {h}", mods.shift); + if legacy && at_edge { + // Legacy edges always stretch their one axis. + assert!((w - 32.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6, "{case}"); + } else { + assert_eq!((w - h).abs() < 1e-6, proportional, "{case}"); + assert!((w - 32.0).abs() < 1e-6, "{case}"); + } + } + } + // ⌘ gestures (skew, distort) are left alone. + assert!(!unlinked_mods(true, false, Hit::Corner(0), egui::Modifiers::COMMAND).shift); + assert!(!unlinked_mods(true, false, Hit::Inside, none).shift); + } + #[test] fn right_click_while_transforming_switches_the_mode() { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); From ec61df6f06c14d9b8feb15a1d30faf51683e8e72 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:55:47 +0300 Subject: [PATCH 059/223] fix(ui): make clickable tab, checkbox, and radio labels non-selectable (#3972) (#4331) * fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972) * test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972) --- crates/ui-egui/src/color_range_ui.rs | 4 +++- crates/ui-egui/src/gallery_ui.rs | 3 ++- crates/ui-egui/src/new_doc_ui.rs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 18084a6cc1..37c90c18e4 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -390,7 +390,9 @@ fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response { if on { ui.painter().circle_filled(r.center(), 3.0, t.accent); } - let l = ui.add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).sense(Sense::click())); + let l = ui + .add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); resp.union(l) }) .inner diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index ed1597deb1..ccf1a7637e 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -609,7 +609,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let r = ui.horizontal(|ui| { let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); crate::icons::paint(ui, ir, icon, 14.0, t.icon); - ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).sense(Sense::click())) + ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) }); if r.inner.clicked() { d.open[ci] = !d.open[ci]; diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index fe37bd0d21..9fe06a1a50 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -450,7 +450,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Date: Tue, 13 Oct 2026 03:56:09 +0300 Subject: [PATCH 060/223] fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) (#4335) * fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) * test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172) --- ATTRIBUTION.md | 3 ++- assets/icons/gradient.svg | 19 +++++++++++++++++++ crates/ui-egui/src/icon_data.rs | 2 ++ crates/ui-egui/src/icons.rs | 24 +++++++++++++++++++++++- crates/ui-egui/src/panels.rs | 2 +- 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 assets/icons/gradient.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8c838b64d9..4b88e0901b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,7 +25,7 @@ next to them. | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `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`, `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`, `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 | @@ -37,6 +37,7 @@ next to them. | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `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/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) | diff --git a/assets/icons/gradient.svg b/assets/icons/gradient.svg new file mode 100644 index 0000000000..8567fc675e --- /dev/null +++ b/assets/icons/gradient.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index dc0bc0b500..4839fdfc76 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -54,6 +54,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")), ("frame-placeholder", include_bytes!("../../../assets/icons/frame-placeholder.svg")), ("gauge", include_bytes!("../../../assets/icons/gauge.svg")), + // PhotoCraft original: the Gradient tool glyph (a rectangle filled with a linear ramp). + ("gradient", include_bytes!("../../../assets/icons/gradient.svg")), ("grid-2x2", include_bytes!("../../../assets/icons/grid-2x2.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0b3b58dd04..390b378716 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,7 +111,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Slice => "slice-knife", Tool::SliceSelect => "square-dashed-mouse-pointer", Tool::Frame => "frame-placeholder", - Tool::Gradient => "blend", + Tool::Gradient => "gradient", Tool::PaintBucket => "paint-bucket", Tool::Type | Tool::VerticalType => "type", Tool::HorizontalTypeMask => "type-mask", @@ -218,6 +218,28 @@ mod tests { for t in Tool::ALL { assert!(exists(tool_icon(t)), "{t:?}"); } + assert_eq!(tool_icon(Tool::Gradient), "gradient"); + assert_ne!(tool_icon(Tool::Gradient), tool_icon(Tool::PaintBucket)); + } + + #[test] + fn gradient_tool_icon_rasterizes_left_to_right_ramp() { + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(tool_icon(Tool::Gradient)) else { + panic!("icon source must be embedded bytes"); + }; + let image = egui_extras::image::load_svg_bytes_with_size( + bytes.as_ref(), + egui::load::SizeHint::Size { width: 24, height: 24, maintain_aspect_ratio: true }, + &Default::default(), + ) + .unwrap(); + let left_alpha = image[(6, 12)].a(); + let mid_alpha = image[(12, 12)].a(); + let right_alpha = image[(18, 12)].a(); + assert!( + left_alpha > mid_alpha && mid_alpha > right_alpha, + "interior ramp should fade from left ({left_alpha}) through middle ({mid_alpha}) to right ({right_alpha})" + ); } #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e8f5803b21..30a3bb400d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1187,7 +1187,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; let before = app.ui.tool_options.clone(); for (style, icon, tip) in [ - ("linear", "blend", tl!("Linear Gradient")), + ("linear", "gradient", tl!("Linear Gradient")), ("radial", "circle", tl!("Radial Gradient")), ("angle", "rotate-cw", tl!("Angle Gradient")), ("reflected", "arrow-left-right", tl!("Reflected Gradient")), From 0d6a83e3ce95e382277d2b4da0ff1437d7b68b23 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:56:18 +0300 Subject: [PATCH 061/223] fix(ui): add Brushes button at top of Brush Settings control list (#4265) (#4354) * fix(ui): add Brushes button at top of Brush Settings control list (#4265) * test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265) --- crates/ui-egui/src/brush_panel.rs | 15 +++++++++++++++ crates/ui-egui/src/brush_panel_tests.rs | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 8f51ca8572..dbf73f46ca 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -248,6 +248,21 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.vertical(|ui| { 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); + } + let icon_rect = egui::Rect::from_center_size(egui::pos2(br_rect.left() + 12.0, br_rect.center().y), vec2(13.0, 13.0)); + icons::paint(ui, icon_rect, "brush", 12.0, if br_resp.hovered() { t.text } else { t.icon }); + let galley = ui.painter().layout_no_wrap(tl!("Brushes").to_string(), theme::semibold(12.0), if br_resp.hovered() { t.text } else { t.text_dim }); + ui.painter().galley(egui::pos2(br_rect.left() + 26.0, br_rect.center().y - galley.size().y / 2.0), galley, t.text); + if br_resp.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.panel.brushes", json!({})); + } + ui.add_space(3.0); + widgets::hairline(ui); + ui.add_space(3.0); for (i, (name, has_box)) in SECTIONS.iter().enumerate() { let toggle_only = checkbox_only(i); // Group the switches with whitespace, not separators between individual rows. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 49ddc0063c..731b13e1a1 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1120,3 +1120,15 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(h.state().ui.views[0].fill_pending); assert!(!h.state().ui.views[0].fit_pending); } + +#[test] +fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { + use egui_kittest::kittest::Queryable; + + let mut h = harness(0, 0); + assert_eq!(h.state().ui.brush_tab, 0); + h.get_by_label("Brushes").click(); + h.run_steps(3); + assert!(h.state().ui.panels.brush_settings); + assert_eq!(h.state().ui.brush_tab, 1); +} From 1874214f79e5b57b6caeca5e024daabea9cab742 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:00:58 +0300 Subject: [PATCH 062/223] fix(ui): keep brush settings section lock icons permanently visible (#4278) (#4300) * fix(ui): keep brush settings section lock icons permanently visible (#4278) * test(ui): verify brush settings locks stay visible and toggle on click (#4278) * Format the lock-icon test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 8 ++-- crates/ui-egui/src/brush_panel_tests.rs | 55 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index dbf73f46ca..4cadb6b212 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -327,11 +327,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); - // Like Photoshop: the lock shows only when set or hovered. - if *lock || resp.hovered() || lresp.hovered() { - let tint = if *lock { t.text } else { t.text_faint }; - icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); - } + // Like Photoshop: lock icons stay visible on every lockable section row. + let tint = if *lock { t.text } else { t.text_faint }; + icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 731b13e1a1..6a2338295c 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -220,6 +220,61 @@ fn section_locks_round_trip_and_survive_preset_picks() { assert_eq!(b.tip, chalk.tip); } +#[test] +fn section_locks_are_always_visible_and_toggle_on_click() { + use egui_kittest::kittest::Queryable; + + fn collect_icon_rects(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(r) if (r.rect.size() - vec2(12.0, 12.0)).length() < 0.5 => out.push(r.rect), + egui::Shape::Mesh(mesh) => out.push(mesh.calc_bounds()), + egui::Shape::Vec(shapes) => { + for s in shapes { + collect_icon_rects(s, out); + } + } + _ => {} + } + } + + let mut h = harness(0, 0); + let mut icons = Vec::new(); + for clipped in &h.output().shapes { + collect_icon_rects(&clipped.shape, &mut icons); + } + + // Every lockable section (1..SECTIONS.len()) paints its lock icon even when unlocked and unhovered. + for (name, _) in SECTIONS.iter().skip(1) { + let row = h.get_by_label(name).rect(); + let lock_center = egui::pos2(row.right() - 11.0, row.center().y); + assert!(icons.iter().any(|r| r.contains(lock_center)), "expected lock icon at {lock_center:?} for {name}"); + } + + // Clicking the lock toggles it on and off without changing the selected section. + let row = h.get_by_label("Scattering").rect(); + let lock_pos = egui::pos2(row.right() - 11.0, row.center().y); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); From 908c47021f1b49cea3c40e90047e824489255bf8 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:05:59 -0600 Subject: [PATCH 063/223] Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) (#4308) * Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill (Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with "not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always edit its mask; the panel's target (brackets, trash, Delete) is unchanged. Co-Authored-By: Claude Opus 5.5 * Keep the web read_dropped doc comment on its function Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 41 ++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 15 +++++--- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 5 +-- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d028918266..362eef2a30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5450,7 +5450,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { } } -/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, else "pixels". +/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, or the layer +/// is an adjustment layer with one (it has no pixels: #4298), else "pixels". pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.active() else { return json!("pixels") }; @@ -5464,7 +5465,8 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); // Viewing the mask (⌥-click its thumbnail, #196) paints the mask. let viewing = photocraft_engine::mask_view_cmds::current(st).is_some(); - json!(if (app.ui.mask_target || viewing) && has_mask { "mask" } else { "pixels" }) + let mask = app.ui.mask_target || viewing || crate::adjustment_layer_active(st); + json!(if mask && has_mask { "mask" } else { "pixels" }) } #[cfg(test)] @@ -6306,6 +6308,41 @@ mod tests { assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01); } + /// #4298: an adjustment layer has no pixels, so after its icon thumbnail is clicked (the + /// Layers panel no longer targets the mask) Fill, the fill keys and the Paint Bucket still + /// edit its mask, as in Photoshop, instead of failing with "not a pixel layer". + #[test] + fn fills_on_an_adjustment_layer_edit_its_mask_when_the_icon_is_targeted() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + // Click the Curves icon thumbnail: the panel targets the layer, not its mask. + app.ui.mask_target = false; + app.sync_mask_targets(); + assert!(!app.ui.mask_target, "the panel's target is the icon"); + let mask_at = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(10, 10, 0) + }; + assert_eq!(paint_target(&app), json!("mask")); + assert!(crate::menus::is_enabled(&app, "edit.fill"), "Edit > Fill (⇧F5) is enabled"); + assert!(crate::menus::is_enabled(&app, "edit.fillForeground")); + app.ui.tool = Tool::PaintBucket; + app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 10.0, y: 10.0 }, egui::Modifiers::NONE); + assert_eq!(mask_at(&app), 0.0, "the Paint Bucket filled the mask black"); + app.run("edit.fill", json!({"contents": "white"})).unwrap(); + assert_eq!(mask_at(&app), 1.0, "Edit > Fill filled the mask white"); + assert!(!app.ui.mask_target, "filling doesn't move the panel's target"); + // A pixel layer with an untargeted mask still paints its pixels. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = false; + assert_eq!(paint_target(&app), json!("pixels")); + assert!(!app.layer_mask_targeted()); + } + #[test] fn alt_with_a_painting_tool_samples_the_foreground_instead_of_painting() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a71d8134a8..bc219f5ff8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1810,6 +1810,13 @@ fn read_dropped(f: &(dyn egui::DroppedFile + Send + Sync)) -> Result, St f.bytes() } +/// The active layer is an adjustment layer. It has no pixels, so painting and filling it edit its +/// mask even when its icon thumbnail, not the mask, is the Layers panel's target, as in +/// Photoshop (#4298). +pub(crate) fn adjustment_layer_active(st: &photocraft_engine::DocState) -> bool { + st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Adjustment(_))) +} + /// On the web, dropped-file bytes arrive asynchronously; the web shell handles drops itself. #[cfg(target_arch = "wasm32")] fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { @@ -1834,9 +1841,9 @@ impl PhotocraftApp { } } - /// The active layer's mask is what pixel commands edit: the Layers panel targets it, or every - /// colour channel is off and that mask is the picture. An alpha channel or Quick Mask wins, - /// as the engine routes those itself. + /// The active layer's mask is what pixel commands edit: the Layers panel targets it, the layer + /// is an adjustment layer (no pixels of its own), or every colour channel is off and that mask + /// is the picture. An alpha channel or Quick Mask wins, as the engine routes those itself. pub fn layer_mask_targeted(&self) -> bool { let Some(st) = self.session.active() else { return false }; let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask; @@ -1845,7 +1852,7 @@ impl PhotocraftApp { if !composite || !has_mask { return false; } - if self.ui.mask_target || layer_mask { + if self.ui.mask_target || layer_mask || adjustment_layer_active(st) { return true; } let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0; diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index ad0e70ca84..e18cb0e9a0 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -629,12 +629,13 @@ fn command_i_inverts_the_targeted_mask() { let mask = &l.mask.as_ref().unwrap().surface; assert_eq!((mask.sample_channel(50, 50, 0), mask.sample_channel(150, 10, 0)), (1.0, 0.0), "the mask inverted"); assert!(l.content == pixels, "the layer's pixels are untouched"); - // An adjustment layer's mask: Invert is enabled only through the mask target. + // An adjustment layer's mask: with no pixels of its own, Invert edits the mask whether the + // panel targets the mask or the icon thumbnail (#4298). let app = h.state_mut(); app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); app.run("layer.layerMask.revealAll", json!({})).unwrap(); app.ui.mask_target = false; - assert!(!crate::menus::is_enabled(app, "image.adjustments.invert")); + assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.ui.mask_target = true; assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.run("image.adjustments.invert", json!({})).unwrap(); From 858fbcf1ad19fe731057bd800c01ef1a48655d9f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:06:58 +0200 Subject: [PATCH 064/223] Object Selection: hover preview of the object it would select (Object Finder) (#4087) (#4095) * WIP: Object Selection hover preview (#4087) * Object Selection: Object Finder hover preview and click-to-select (#4087) Hovering with Object Selection highlights the object a click selects, computed off the UI thread after a short rest and cached per document pixels; a click commits it via select.object {point} honouring add/subtract/intersect; dragging keeps the rectangle mode. Adds an 'Object Finder' options-bar toggle (default on, persisted) with translations in every catalog. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/grabcut.rs | 144 +++++++++++ crates/algo/src/segment/tests.rs | 21 ++ crates/engine/src/lib.rs | 5 + crates/engine/src/smartselect_cmds.rs | 173 +++++++++++++- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/object_finder.rs | 331 ++++++++++++++++++++++++++ crates/ui-egui/src/retouch_ui.rs | 19 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/tool_reset.rs | 3 +- docs/ui-parity.md | 2 +- 30 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/object_finder.rs diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 5e80791ff1..ae1a586c12 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -116,3 +116,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } + +/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer +/// hovers. +pub const OBJECT_AT_WORK_PX: usize = 60_000; + +/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`. +/// +/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the +/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both +/// by the distance to the seed's running mean and by the local edge step), so it stops at the +/// object's outline. A region that grows over most of the canvas is the background: nothing is +/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// foreground, the cursor definite foreground, outside the box definite background), the part +/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the +/// rectangle mode does. +pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { + if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + if !canvas.contains(px, py) { + return None; + } + let step = super::scale_for(canvas, work_px.max(1024)); + let img = sampler.rgb_scaled(canvas, step); + let (w, h) = (img.w, img.h); + if w < 2 || h < 2 { + return None; + } + // Lightly smoothed, so noise and texture don't stop the growth; edges survive. + let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect(); + let sx = (((px - canvas.x0) as usize) / step).min(w - 1); + let sy = (((py - canvas.y0) as usize) / step).min(h - 1); + let grown = grow_similar(&lab, w, h, sx, sy); + let count = grown.iter().filter(|g| **g).count(); + // Background (or a flat image): no single object under the cursor. + if count * 10 > w * h * 6 || count < 4 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, g) in grown.iter().enumerate() { + if *g { + let (gx, gy) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1)); + } + } + let pad = ((x1 - x0).max(y1 - y0) / 6).max(2); + let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h)); + let mut trimap = vec![BG; w * h]; + let mut any_bg = false; + for yy in 0..h { + for xx in 0..w { + let i = yy * w + xx; + let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1; + trimap[i] = if grown[i] { + PR_FG + } else if inside { + PR_BG + } else { + any_bg = true; + BG + }; + } + } + if !any_bg { + // The box is the whole canvas: the frame is background. + for yy in 0..h { + for xx in 0..w { + if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) { + trimap[yy * w + xx] = BG; + } + } + } + } + trimap[sy * w + sx] = FG; + let (fg, bg) = grabcut(&img, &mut trimap, 5)?; + let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); + let mut seeds = vec![false; w * h]; + seeds[sy * w + sx] = true; + let low = super::keep_seeded(&low, &seeds, w, h); + let low = super::clean_mask(&low, w, h, 0.1, 0.01); + if !low.iter().any(|v| *v) { + return None; + } + super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg))) +} + +/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a +/// distance of the seed area's running mean and whose step from the neighbour it was reached +/// from is small, so it stops at edges. +fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec { + const MEAN_DIST: f32 = 32.0; + const EDGE_STEP: f32 = 10.0; + let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + let mut out = vec![false; w * h]; + let Some(&seed) = lab.get(sy * w + sx) else { return out }; + let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64); + let mut stack = vec![(sx, sy)]; + out[sy * w + sx] = true; + while let Some((x, y)) = stack.pop() { + let c = lab[y * w + x]; + let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32]; + let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)]; + for (nx, ny) in nbrs { + if nx >= w || ny >= h { + continue; + } + let i = ny * w + nx; + if out[i] { + continue; + } + let q = lab[i]; + if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST { + out[i] = true; + sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64]; + n += 1.0; + stack.push((nx, ny)); + } + } + } + out +} + +/// 3×3 box blur (clamped at the edges). +fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> { + let mut out = vec![[0.0f32; 3]; px.len()]; + for y in 0..h { + for x in 0..w { + let (mut acc, mut n) = ([0.0f32; 3], 0.0f32); + for yy in y.saturating_sub(1)..(y + 2).min(h) { + for xx in x.saturating_sub(1)..(x + 2).min(w) { + if let Some(p) = px.get(yy * w + xx) { + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + n += 1.0; + } + } + } + if let Some(o) = out.get_mut(y * w + x) { + *o = acc.map(|v| v / n.max(1.0)); + } + } + } + out +} diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 4f741eb227..869f3c6717 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -659,3 +659,24 @@ fn dump_quick_scenes() { save("thin_line", &line); save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0); } + +#[test] +fn object_at_finds_the_disc_under_the_cursor() { + let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap(); + let v = iou(®, &truth, 320, 240); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn object_at_on_the_background_or_off_canvas_finds_nothing() { + let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 45cf3418fe..1cc8eb692b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -432,6 +432,9 @@ pub struct Session { pub link_scan: Option, /// Select Subject's last result, reused while the pixels are unchanged (#2865). subject_cache: smartselect_cmds::SubjectCache, + /// Object Selection's Object Finder results (#4087), shared by the hover preview and the + /// click that commits it; valid while the pixels are unchanged. + pub object_finder: smartselect_cmds::ObjectFinderCache, /// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it /// ([`Session::offer_live_stroke`]). pub(crate) live_commit: Option, @@ -528,6 +531,7 @@ impl Session { if let Some(id) = self.docs.get(index).map(|d| d.doc.id) { self.cancel_jobs_on(id); self.subject_cache.forget_doc(id); + self.object_finder.forget_doc(id); } let d = self.docs.remove(index); self.active = if self.docs.is_empty() { @@ -701,6 +705,7 @@ impl Session { if damage.is_none_or(|d| !d.is_empty()) { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); + self.object_finder = Default::default(); } Ok(r) } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 6c15c6be56..b04aae7a84 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -130,8 +130,124 @@ fn rect_param(p: &Value) -> Option { Some(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) } +fn point_param(p: &Value) -> Option<(f32, f32)> { + let a = p.get("point")?.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then_some((x, y)) +} + +/// The layer [`with_doc_sampler`] samples for `layer` / `all_layers`: `None` = the composite. +pub fn sampled_target(doc: &Document, layer: Option, all_layers: bool) -> Option { + layer.filter(|id| !all_layers && doc.layer(*id).and_then(Layer::surface).is_some()) +} + +/// Object Finder (#4087): the object Object Selection would select at document point `(x, y)`, +/// sampling `layer` (or the composite). Pure, so the UI can run it on a worker thread while the +/// pointer hovers; `select.object` with `"point"` commits the same result. +pub fn find_object(doc: &Document, layer: Option, all_layers: bool, x: f32, y: f32) -> Option { + with_doc_sampler(doc, layer, all_layers, |smp, doc| grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX)) +} + +/// Most objects remembered by [`ObjectFinderCache`]. +pub const OBJECT_FINDER_ENTRIES: usize = 12; + +/// Object Finder results for one document's pixels (#4087): hovering over an object already +/// found, or clicking it, answers at once. Keyed like [`SubjectCache`] (valid while the pixels +/// are the same, the document held weakly) plus the sampled layer; a lookup hits any cached +/// object that covers the point, so one computation serves the whole object. +#[derive(Debug, Default)] +pub struct ObjectFinderCache { + doc: std::sync::Weak, + doc_id: Option, + target: Option, + regions: Vec>, +} + +impl ObjectFinderCache { + fn valid(&self, doc: &Document, target: Option) -> bool { + self.doc_id == Some(doc.id) && self.target == target && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) + } + + /// A cached object of `doc` (sampling `target`) covering `(x, y)`. + pub fn lookup(&self, doc: &Document, target: Option, x: f32, y: f32) -> Option> { + if !x.is_finite() || !y.is_finite() || !self.valid(doc, target) { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + self.regions.iter().find(|r| r.at(px, py) >= 0.5).cloned() + } + + /// Remembers `region`, found in `doc` sampling `target`. A different document or pixels + /// start over; the oldest object is dropped beyond [`OBJECT_FINDER_ENTRIES`]. + pub fn insert(&mut self, doc: &std::sync::Arc, target: Option, region: std::sync::Arc) { + if !self.valid(doc, target) { + self.regions.clear(); + } + self.doc = std::sync::Arc::downgrade(doc); + self.doc_id = Some(doc.id); + self.target = target; + if self.regions.len() >= OBJECT_FINDER_ENTRIES { + self.regions.remove(0); + } + self.regions.push(region); + } + + /// Number of remembered objects. + pub fn len(&self) -> usize { + self.regions.len() + } + + /// Whether nothing is remembered. + pub fn is_empty(&self) -> bool { + self.regions.is_empty() + } + + /// Forget the results if they were computed from the document `id` (it is closing). + pub(crate) fn forget_doc(&mut self, id: photocraft_doc::DocId) { + if self.doc_id == Some(id) { + *self = Self::default(); + } + } + + /// Follows `doc` past a selection-only edit (same pixels), so the next lookup still hits. + fn follow(&mut self, doc: &std::sync::Arc) { + if self.doc_id == Some(doc.id) && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) { + self.doc = std::sync::Arc::downgrade(doc); + } + } +} + +/// `select.object` with `"point"`: the Object Finder's object under the point (cached). +fn object_at_point(s: &mut Session, p: &Value, (x, y): (f32, f32)) -> Result { + let all_layers = b(p, "sampleAllLayers", false); + let d = s.active().ok_or(EngineError::NoDocument)?; + let target = sampled_target(&d.doc, d.active_layer, all_layers); + let region = match s.object_finder.lookup(&d.doc, target, x, y) { + Some(r) => Some(r), + None => { + let doc = std::sync::Arc::clone(&d.doc); + let found = find_object(&doc, d.active_layer, all_layers, x, y).map(std::sync::Arc::new); + if let Some(r) = &found { + s.object_finder.insert(&doc, target, std::sync::Arc::clone(r)); + } + found + } + }; + let out = apply(s, "Object Selection", region.as_deref(), mode(p, "replace"))?; + if let Some(d) = s.active() { + let doc = std::sync::Arc::clone(&d.doc); + s.object_finder.follow(&doc); + } + Ok(out) +} + fn object_selection(s: &mut Session, p: &Value) -> Result { - let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h]"))?; + if p.get("rect").is_none() + && let Some(pt) = point_param(p) + { + return object_at_point(s, p, pt); + } + let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h] or \"point\": [x, y]"))?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region.as_ref(), mode(p, "replace")) } @@ -373,7 +489,7 @@ pub fn specs() -> Vec { "select.object", "Object Selection", [], - r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, + r##"{"rect":[x,y,w,h] | "point":[x,y],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false} (point: the Object Finder's object under the point, as hovering previews it)"##, has_doc, object_selection ), @@ -544,6 +660,59 @@ mod tests { assert!(cov(&s, 10, 10) >= 0.99); } + #[test] + fn object_selection_point_selects_the_object_under_it() { + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "depth {depth}: IoU {v}"); + } + // The background is no object: nothing changes. + let (mut s, _) = disc(8); + let r = s.execute("select.object", json!({"point": [3, 3]})).unwrap(); + assert_eq!(r["changed"], false); + assert!(s.active().unwrap().doc.selection.is_none()); + assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); + } + + #[test] + fn object_selection_point_honours_the_modes() { + let (mut s, _) = disc(8); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.object", json!({"point": [100, 80], "mode": "subtract"})).unwrap(); + assert!(cov(&s, 100, 80) < 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "add"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "intersect"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) < 0.5); + } + + #[test] + fn object_finder_cache_serves_the_whole_object_until_pixels_change() { + let (mut s, _) = disc(8); + let d = s.active().unwrap(); + let doc = std::sync::Arc::clone(&d.doc); + let target = sampled_target(&doc, d.active_layer, false); + assert!(s.object_finder.lookup(&doc, target, 100.0, 80.0).is_none()); + let r = std::sync::Arc::new(find_object(&doc, d.active_layer, false, 100.0, 80.0).unwrap()); + s.object_finder.insert(&doc, target, std::sync::Arc::clone(&r)); + // Any point on the object hits; the background and another target miss. + assert!(std::sync::Arc::ptr_eq(&s.object_finder.lookup(&doc, target, 120.0, 90.0).unwrap(), &r)); + assert!(s.object_finder.lookup(&doc, target, 3.0, 3.0).is_none()); + assert!(s.object_finder.lookup(&doc, Some(LayerId(9999)), 100.0, 80.0).is_none()); + // The click reuses it, and a selection-only edit keeps it. + s.execute("select.object", json!({"point": [110, 70]})).unwrap(); + assert_eq!(s.object_finder.len(), 1); + s.execute("select.deselect", json!({})).unwrap(); + let d = s.active().unwrap(); + assert!(s.object_finder.lookup(&std::sync::Arc::clone(&d.doc), target, 100.0, 80.0).is_some()); + // A pixel edit drops it. + s.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + assert!(s.object_finder.is_empty()); + } + #[test] fn object_selection_disc() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 362eef2a30..bfcb0d1a7f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3438,6 +3438,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let resizing = crate::brush_resize::draw(app, &painter, &xf); draw_transform_controls(app, &painter, &xf); crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos()); + crate::object_finder::draw(app, &painter, &xf, idx, response.hover_pos()); crate::layer_pick_ui::show(app, &ctx); let shown = app.ui.views.get(idx).cloned(); crate::canvas_tool_menu::show(app, &ctx); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 89fa3da9f6..8c2bac34c7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3658,6 +3658,8 @@ navigator Zoom Zvětšení Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá + Object Finder Hledání objektů + No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník 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ší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fde840b1ed..3e5c38703a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2690,6 +2690,8 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Object Finder Objektsucher + No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum 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) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a3da64e9dc..01582d5d71 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3748,6 +3748,8 @@ curves Smooth Εξομάλυνση Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε Light Gray Ανοιχτό γκρι + Object Finder Εύρεση αντικειμένων + 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 ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 96ecc18d39..d3a61f70bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2731,6 +2731,8 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Object Finder Buscador de objetos + No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor 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) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3f9ec9fb37..b1eb9c5e00 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3051,6 +3051,8 @@ curves Smooth Lisser Light Red Rouge clair Medium Blue Bleu moyen Light Gray Gris clair + Object Finder Outil de recherche d’objets + No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour 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) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f37ef4d335..626b84cd16 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3993,4 +3993,6 @@ cameraRaw Yellow Sárga Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet + Object Finder Objektumkereső + No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 18d2c776f8..e29822449a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3038,6 +3038,8 @@ curves Smooth Haluskan Light Red Merah Terang Medium Blue Biru Sedang Light Gray Abu-abu Muda + Object Finder Pencari Objek + No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya 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) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index de47f7bcaa..1c6ba0ec77 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4014,6 +4014,8 @@ navigator Zoom Zoom {index} of {total} {index} di {total} {shortcut} assigned. {shortcut} assegnata. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. + Object Finder Ricerca oggetti + No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno 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) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f2d3bd6694..761501e85c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3660,6 +3660,8 @@ curves Smooth スムーズ Light Red ライトレッド Medium Blue ミディアムブルー Light Gray ライトグレー + Object Finder オブジェクトファインダー + 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 で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 84f30fc19c..b280416893 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3220,6 +3220,8 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Object Finder 개체 찾기 + 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 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fde9f99743..cf570d4021 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3810,6 +3810,8 @@ curves Smooth Vloeiend maken Light Red Lichtrood Medium Blue Middelblauw Light Gray Lichtgrijs + Object Finder Objectzoeker + No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen 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) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 29dfcef28d..cc0b77df70 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3647,6 +3647,8 @@ curves Smooth Wygładź Light Red Jasnoczerwony Medium Blue Średnioniebieski Light Gray Jasnoszary + Object Finder Wyszukiwanie obiektów + No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt 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) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a68ba566ff..bbe1fd8e7f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3840,6 +3840,8 @@ layerLabel No Color Sem cor Light Red Vermelho-claro Medium Blue Azul médio Light Gray Cinza-claro + Object Finder Localizador de objetos + No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele 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) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6caf04cb9..3e4e0b0097 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2995,6 +2995,8 @@ curves Smooth Сгладить Light Red Светло-красный Medium Blue Средне-синий Light Gray Светло-серый + Object Finder Поиск объектов + 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 отменяет) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d944622ec2..419c54c8ab 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3805,4 +3805,6 @@ cameraData Lens Lens Action playback stopped Eylem oynatma durduruldu Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor @plural {n} action|{n} actions {n} eylem + Object Finder Nesne Bulucu + No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bd93075fa2..c5c724c92c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3748,6 +3748,8 @@ curves Smooth Згладити Light Red Світло-червоний Medium Blue Середньо-синій Light Gray Світло-сірий + Object Finder Пошук об’єктів + 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 скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea93bc326c..6872d8cf6b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3534,6 +3534,8 @@ 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ơ? + 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) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9e66946f4f..beb1b0a5cb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2655,6 +2655,8 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Object Finder 对象查找程序 + 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 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b7685958e3..c3772fc1c5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2640,6 +2640,8 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Object Finder 物件尋找器 + 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 取消) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc219f5ff8..cef61293f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,7 @@ pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; mod numeric_expression; +pub mod object_finder; mod opacity_keys; pub mod outline; pub mod paint_mouse; @@ -511,6 +512,8 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). pub(crate) move_hover: Option, + /// Object Selection › Object Finder: the hover preview (`object_finder`). + pub(crate) object_finder: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). @@ -739,6 +742,7 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, move_hover: None, + object_finder: None, blend_preview: None, patch_preview: None, magnetic: Default::default(), diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs new file mode 100644 index 0000000000..e219b0ddff --- /dev/null +++ b/crates/ui-egui/src/object_finder.rs @@ -0,0 +1,331 @@ +//! Object Selection › Object Finder (#4087): with the Object Selection tool active, resting the +//! pointer on the canvas highlights the object a click there would select, as Photoshop's Object +//! Finder does. A click (without dragging) commits that object (`select.object` with `"point"`; +//! ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode. +//! +//! The lookup is `photocraft_engine::smartselect_cmds::find_object`, run after the pointer has +//! been still for [`DEBOUNCE`] seconds on a worker thread (inline on the web), so the UI never +//! waits for it. Results go into the session's `object_finder` cache, which the click reuses and +//! which serves every point of an object already found; it is keyed by the document's pixels, +//! so an edit drops it. The preview itself is keyed by document revision, sampled layer and +//! Sample All Layers, and clears when the pointer leaves the canvas, the tool changes, a button +//! goes down or the option is off. Quality of the object found is out of scope here (#2788). + +use std::sync::Arc; + +use egui::{Color32, TextureOptions, pos2}; +use photocraft_algo::selection::Region; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// Seconds the pointer must rest before an object is looked up. +pub const DEBOUNCE: f64 = 0.12; +/// Longest side of the overlay texture (the overlay is a preview, not the selection). +const TEX_MAX: usize = 768; + +/// A lookup running off the UI thread. +struct Job { + at: [i32; 2], + doc: Arc, + rx: std::sync::mpsc::Receiver>, +} + +/// The hover state of the active document (`PhotocraftApp::object_finder`). +pub(crate) struct Finder { + doc: DocId, + revision: u64, + target: Option, + all_layers: bool, + /// The pointer pixel and when it got there. + at: [i32; 2], + since: f64, + job: Option, + /// Points already looked up that found nothing (the background). + misses: Vec<[i32; 2]>, + shown: Option>, + texture: Option<(usize, egui::TextureHandle)>, +} + +/// What [`update`] decided. +#[derive(Clone, Debug, Default)] +pub(crate) struct Update { + /// The object to highlight. + pub shown: Option>, + /// A lookup is pending or running: repaint soon to pick it up. + pub busy: bool, +} + +/// Whether the preview applies now (`pressed`: a pointer button is down). +fn active(app: &PhotocraftApp, pressed: bool) -> bool { + app.ui.tool == Tool::ObjectSelection + && app.ui.tool_options.object_finder + && !pressed + && app.drag.is_none() + && app.ui.transform.is_none() + && app.session.active().is_some() +} + +/// Advances the hover preview with the pointer at `at` (document px; `None` = off the canvas) +/// at time `now` (seconds). +pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: bool, now: f64) -> Update { + let at = at.filter(|p| p[0].is_finite() && p[1].is_finite()); + let (Some(at), true) = (at, active(app, pressed)) else { + app.object_finder = None; + return Update::default(); + }; + let Some(st) = app.session.active() else { return Update::default() }; + let all_layers = app.ui.tool_options.sample_all_layers; + let target = sampled_target(&st.doc, st.active_layer, all_layers); + let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); + let px = [at[0].floor() as i32, at[1].floor() as i32]; + let fresh = app.object_finder.as_ref().is_some_and(|f| f.doc == doc && f.revision == revision && f.target == target && f.all_layers == all_layers); + if !fresh { + app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); + } + let doc_arc = Arc::clone(&st.doc); + let Some(f) = app.object_finder.as_mut() else { return Update::default() }; + if f.at != px { + f.at = px; + f.since = now; + } + // A finished lookup: remember it (only for the pixels it was computed from). + if let Some(job) = &f.job { + match job.rx.try_recv() { + Ok(found) => { + if let Some(r) = found { + if photocraft_engine::layer_multi_cmds::same_pixels(&job.doc, &doc_arc) { + app.session.object_finder.insert(&doc_arc, target, Arc::new(r)); + } + } else { + f.misses.push(job.at); + if f.misses.len() > 64 { + f.misses.remove(0); + } + } + f.job = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => f.job = None, + } + } + let inside = |r: &Region| r.at(px[0], px[1]) >= 0.5; + if !f.shown.as_deref().is_some_and(inside) { + f.shown = app.session.object_finder.lookup(&doc_arc, target, at[0] as f32, at[1] as f32); + } + let missed = f.misses.iter().any(|m| (m[0] - px[0]).abs() <= 2 && (m[1] - px[1]).abs() <= 2); + if f.shown.is_none() && f.job.is_none() && !missed && now - f.since >= DEBOUNCE && doc_arc.bounds().contains(px[0], px[1]) { + f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); + } + let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); + Update { shown: f.shown.clone(), busy } +} + +fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { + let (tx, rx) = std::sync::mpsc::channel(); + let work = { + let doc = Arc::clone(&doc); + move || { + let _ = tx.send(find_object(&doc, layer, all_layers, at[0] as f32, at[1] as f32)); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + let spawned = std::thread::Builder::new().name("object-finder".into()).spawn(work); + if spawned.is_err() { + // No thread: the receiver reports Disconnected and the next rest retries. + } + } + #[cfg(target_arch = "wasm32")] + work(); + Job { at: px, doc, rx } +} + +/// Clears the preview (a tool switch or an edit made elsewhere is caught by [`update`] too). +pub(crate) fn clear(app: &mut PhotocraftApp) { + app.object_finder = None; +} + +/// The overlay colour: the theme accent, translucent (Photoshop tints the object it finds). +fn overlay(accent: Color32, cov: f32, edge: bool) -> Color32 { + let a = if edge { 0.95 } else { 0.42 * cov.clamp(0.0, 1.0) }; + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (a * 255.0).round() as u8) +} + +/// The overlay image of `r`: translucent fill with an opaque one-texel outline, at most +/// [`TEX_MAX`] texels on its longest side. +pub(crate) fn overlay_image(r: &Region, accent: Color32) -> egui::ColorImage { + let (w, h) = (r.bbox.width().max(1) as usize, r.bbox.height().max(1) as usize); + let step = w.max(h).div_ceil(TEX_MAX).max(1); + let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); + let cov = |tx: usize, ty: usize| r.at(r.bbox.x0 + (tx * step) as i32, r.bbox.y0 + (ty * step) as i32); + let mut px = Vec::with_capacity(tw * th); + for ty in 0..th { + for tx in 0..tw { + let c = cov(tx, ty); + let on = c >= 0.5; + let edge = on + && (tx == 0 + || ty == 0 + || tx + 1 == tw + || ty + 1 == th + || cov(tx - 1, ty) < 0.5 + || cov(tx + 1, ty) < 0.5 + || cov(tx, ty - 1) < 0.5 + || cov(tx, ty + 1) < 0.5); + px.push(if c <= 0.0 { Color32::TRANSPARENT } else { overlay(accent, c, edge) }); + } + } + egui::ColorImage::new([tw, th], px) +} + +/// Updates the preview for this frame and draws the highlight on the active document's canvas. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let ctx = painter.ctx().clone(); + let (pressed, now) = ctx.input(|i| (i.pointer.any_down(), i.time)); + let up = update(app, hover.map(|p| xf.to_doc(p)), pressed, now); + if up.busy { + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + } + let Some(r) = up.shown else { return }; + let Some(f) = app.object_finder.as_mut() else { return }; + let key = Arc::as_ptr(&r) as usize; + if f.texture.as_ref().is_none_or(|(k, _)| *k != key) { + let accent = crate::theme::Tokens::get(&ctx).accent; + let tex = ctx.load_texture("object-finder", overlay_image(&r, accent), TextureOptions::LINEAR); + f.texture = Some((key, tex)); + } + let Some((_, tex)) = &f.texture else { return }; + let b = r.bbox; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let corners = [(b.x0, b.y0, 0.0, 0.0), (b.x1, b.y0, 1.0, 0.0), (b.x1, b.y1, 1.0, 1.0), (b.x0, b.y1, 0.0, 1.0)]; + for (x, y, u, v) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: Color32::WHITE }); + } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// A 200 × 160 document: a red disc (centre 100, 80, radius 45) on blue. + pub(crate) fn disc_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.session + .edit("paint", |doc, a| { + let s = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 200, 160), &[0.2, 0.4, 0.8, 1.0]); + for y in 0..160 { + for x in 0..200 { + let (dx, dy) = (x as f32 + 0.5 - 100.0, y as f32 + 0.5 - 80.0); + if dx * dx + dy * dy <= 45.0 * 45.0 { + s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[0.9, 0.2, 0.1, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::ObjectSelection; + app + } + + /// Hovers at `at` until the lookup settles (time advances past the debounce). + pub(crate) fn hover(app: &mut PhotocraftApp, at: [f64; 2]) -> Update { + let mut t = 100.0; + loop { + let u = update(app, Some(at), false, t); + if !u.busy || t > 130.0 { + return u; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + t += 0.05; + } + } + + #[test] + fn hovering_an_object_highlights_it_after_a_rest() { + let mut app = disc_app(); + // Not before the pointer has rested. + let u = update(&mut app, Some([100.0, 80.0]), false, 50.0); + assert!(u.shown.is_none() && u.busy); + let u = hover(&mut app, [100.0, 80.0]); + let r = u.shown.expect("the disc is highlighted"); + assert!(r.at(100, 80) >= 0.5 && r.at(60, 80) >= 0.5 && r.at(5, 5) < 0.5); + // Elsewhere on the same object: served from the cache, no new lookup. + let u = update(&mut app, Some([120.0, 90.0]), false, 200.0); + assert!(u.shown.is_some_and(|s| Arc::ptr_eq(&s, &r)) && !u.busy); + assert_eq!(app.session.object_finder.len(), 1); + // The background highlights nothing. + assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); + } + + #[test] + fn preview_clears_on_leave_tool_switch_button_option_and_edits() { + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, None, false, 300.0).shown.is_none()); + assert!(app.object_finder.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, Some([100.0, 80.0]), true, 300.0).shown.is_none()); + app.ui.tool = Tool::Lasso; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool_options.object_finder = true; + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + // A pixel edit: the cached object is dropped and the preview starts over. + app.session.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 400.0); + assert!(u.shown.is_none() && u.busy); + } + + #[test] + fn click_commits_the_highlighted_object_with_the_modifiers() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + let click = |app: &mut PhotocraftApp, m: egui::Modifiers| { + tool_event(app, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 100.0, y: 80.0 }, m); + }; + let cov = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.object")); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 60, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + app.run("select.all", json!({})).unwrap(); + click(&mut app, egui::Modifiers::ALT); + assert!(cov(&app, 100, 80) < 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers::SHIFT); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + // Off: a click keeps the Quick Selection fallback. + app.ui.tool_options.object_finder = false; + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.quick")); + } + + #[test] + fn overlay_image_tints_the_object_and_outlines_it() { + let r = Region { bbox: photocraft_geom::Rect::new(0, 0, 4, 4), mask: vec![255; 16] }; + let img = overlay_image(&r, Color32::from_rgb(0, 120, 255)); + assert_eq!(img.size, [4, 4]); + assert!(img.pixels[0].a() > 200, "edge"); + assert!(img.pixels[5].a() > 0 && img.pixels[5].a() < 200, "fill"); + let big = Region { bbox: photocraft_geom::Rect::new(0, 0, 3000, 10), mask: vec![255; 30_000] }; + assert!(overlay_image(&big, Color32::WHITE).size[0] <= TEX_MAX); + } +} diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index a9e1241e07..48beefbaaa 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -342,13 +342,28 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value } /// Object Selection: the dragged rectangle. A click (or a sub-2 px nudge) cannot be a -/// rectangle: like Photoshop's click-to-select, it falls back to a Quick Selection dab at +/// rectangle: with Object Finder on it selects the object under the pointer, the one hovering +/// highlighted (#4087; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects, otherwise replaces). With it off it +/// falls back to a Quick Selection dab at /// the brush size instead of looking dead (⌥ subtracts, otherwise it adds). Larger drags /// run the rectangle GrabCut (⌥ subtracts, ⇧ adds, otherwise replaces). pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); let sample_all = app.ui.tool_options.sample_all_layers; + if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { + // Object Finder (#4087): the object under the click, as hovering highlighted it. + let mode = match (mods.shift, mods.alt) { + (true, true) => "intersect", + (true, false) => "add", + (false, true) => "subtract", + (false, false) => "replace", + }; + let r = app.run("select.object", json!({"point": [end[0], end[1]], "mode": mode, "sampleAllLayers": sample_all})); + crate::object_finder::clear(app); + report_smart_result(app, r, tl!("No object found there — drag a rectangle around it")); + return; + } if w < 2.0 || h < 2.0 { let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; @@ -600,6 +615,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } Tool::ObjectSelection => { + crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); @@ -776,6 +792,7 @@ mod tests { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. let mut app = app(); app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; let m = egui::Modifiers::NONE; tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 30.0, pressure: 1.0 }, m); tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 30.0 }, m); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ff76da78c8..620adf8d23 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -508,6 +508,8 @@ pub struct ToolOptions { pub contiguous: bool, pub anti_alias: bool, pub sample_all_layers: bool, + /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). + pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). pub remove_new_layer: bool, /// Remove tool: each stroke is filled when released (Photoshop's default). Off, the strokes @@ -750,6 +752,7 @@ impl Default for ToolOptions { contiguous: true, anti_alias: true, sample_all_layers: false, + object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, feather: 0.0, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 646af4f7a3..8743fc1059 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -124,7 +124,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection | Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge), + Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), + Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70b503d0c8..a48db144ae 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | From 94ea94f8899171b08a94052abcc3ded200c2b01f Mon Sep 17 00:00:00 2001 From: "cam." <33173989+xtellarvoid@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:14:16 +0700 Subject: [PATCH 065/223] feat(ui): add GRD import option to gradient map preset picker (#4337) * feat(ui): add GRD import option to gradient preset picker * Translate the GRD import strings into Vietnamese, Turkish and Hungarian Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/adjust_dialog.rs | 3 + crates/ui-egui/src/adjust_editors.rs | 18 +++- crates/ui-egui/src/adjust_editors_tests.rs | 96 ++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/preset_files_ui.rs | 17 ++++ 23 files changed, 189 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index a9235881ea..5f43348507 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -209,6 +209,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Self { - Edit { changed, commit: changed } + Edit { changed, commit: changed, ..Default::default() } } fn of(r: &egui::Response) -> Self { - Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()) } + Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()), ..Default::default() } } } @@ -1388,6 +1391,14 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { .icon(widgets::chevron_icon) .close_behavior(close) .show_ui(ui, |ui| { + let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options")); + egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| { + if ui.button(tl!("Import .GRD")).clicked() { + e.import_gradients = true; + ui.close(); + } + }); + ui.separator(); for g in &cx.gradients { ui.label(RichText::new(&g.name).color(t.text_faint)); for p in &g.items { @@ -1617,6 +1628,9 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj crate::layer_picker::controls(app, ui, id); } let e = editor(ui, kind, &mut values, &cx); + if e.import_gradients { + crate::preset_files_ui::import_gradients_dialog(app); + } if e.changed { app.live_adjust = Some((id, values.clone())); } diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 805bbbdd90..9c39088bef 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -592,6 +592,7 @@ fn gradient_map_picker_offers_the_gradient_library() { let mut h = app_harness("gradientMap", "rgb", 8); // A short library so every row fits the popup: Basics, Blues and a user-saved group. let mut lib: Vec<_> = gradients::builtin().into_iter().take(2).collect(); + lib[1].items.truncate(3); lib.push(Group::new("Mine", vec![gradients::GradientPreset::new("My Teal", &["#008080", "#ffee00"])])); h.state_mut().session.presets.gradients = lib; h.run_steps(2); @@ -611,6 +612,101 @@ fn gradient_map_picker_offers_the_gradient_library() { } } +fn import_gradients_from_picker(h: &mut Harness<'_, PhotocraftApp>) { + let preset = label_rect(h, "Preset:").unwrap(); + click(h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("Gradient Preset Options").click(); + h.run_steps(2); + h.get_by_label("Import .GRD").click(); + h.run_steps(3); +} + +#[test] +fn gradient_map_picker_imports_grd_without_editing_the_map() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop}; + let mut h = app_harness("gradientMap", "rgb", 16); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let history = h.state().session.active().unwrap().history.past_len(); + let tool = h.state().session.presets.gradient.clone(); + let bytes = photocraft_psd::grd::write(&[GrdGradient { + name: "Imported Teal".into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([0.0, 0.5, 0.5]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 0.0]) }, + ], + ..Default::default() + }]); + h.state_mut().services.file_dialog = Some(Box::new(move |request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { multiple: false, extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(Some(crate::FileDialogAnswer::Contents("My Gradients.GRD".into(), bytes.clone()))); + })); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before, "import does not edit the adjustment"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().session.presets.gradient, tool); + assert!(!h.state().ui.status_error); + assert!(h.state().ui.status.contains("Imported 1 gradient")); + let preset = label_rect(&h, "Preset:").unwrap(); + click(&mut h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("My Gradients"); + h.get_by_label("Imported Teal").click(); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 128, 128]), (1.0, [255, 255, 0])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&h), before); +} + +#[test] +fn gradient_map_picker_import_cancel_and_errors_preserve_state() { + use crate::FileDialogAnswer; + for answer in [ + None, + Some(FileDialogAnswer::Contents("broken.grd".into(), b"invalid".to_vec())), + Some(FileDialogAnswer::Contents("old.grd".into(), b"8BGR\0\x03".to_vec())), + Some(FileDialogAnswer::Contents( + "noise.grd".into(), + photocraft_psd::grd::write(&[photocraft_psd::grd::GrdGradient { noise: true, ..Default::default() }]), + )), + Some(FileDialogAnswer::Contents("wrong.abr".into(), Vec::new())), + Some(FileDialogAnswer::Paths(vec!["/nonexistent/photocraft-gradients.grd".into()])), + Some(FileDialogAnswer::Unavailable("dialog unavailable".into())), + ] { + let mut h = app_harness("gradientMap", "rgb", 8); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let presets = h.state().session.presets.gradients.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + let error = answer.is_some(); + h.state_mut().services.file_dialog = Some(Box::new(move |_, _, reply| reply.send(answer.clone()))); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before); + assert_eq!(h.state().session.presets.gradients, presets); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().ui.status_error, error); + if error { + assert!(!h.state().ui.status.is_empty()); + } + } +} + +#[test] +fn gradient_map_dialog_import_preserves_pending_settings() { + let mut h = app_harness("gradientMap", "rgb", 8); + let id = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.gradientMap").unwrap(); + let fields = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + h.state_mut().services.file_dialog = Some(Box::new(|request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(None); + })); + h.run_steps(3); + import_gradients_from_picker(&mut h); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields, fields); + assert!(!h.state().file_dialog_open()); + assert!(!h.state().ui.status_error); +} + /// Document point (x, y) on screen in the app's first view. fn doc_on_screen(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) -> Pos2 { let app = h.state(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8c2bac34c7..11438131d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1162,6 +1162,9 @@ Ignore All Ignorovat vše Image Size Velikost obrazu Import Brushes… Importovat štětce… + Gradient Preset Options Možnosti předvoleb přechodu + Import .GRD Importovat .GRD + Choose a gradient file (.grd). Vyberte soubor přechodů (.grd). Import brushes (.abr) Importovat štětce (.abr) Import Presets Importovat přednastavení Imported Importováno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3e5c38703a..a9b676634b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1142,6 +1142,9 @@ Ignore All Alle ignorieren Image Size Bildgröße Import Brushes… Pinsel importieren… + Gradient Preset Options Verlaufsvorgabenoptionen + Import .GRD .GRD importieren + Choose a gradient file (.grd). Wählen Sie eine Verlaufsdatei (.grd). Import brushes (.abr) Pinsel (.abr) importieren Import Presets Vorgaben importieren Imported Importiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 01582d5d71..5c33da43ca 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -273,6 +273,9 @@ Filter for shape layers Φιλτράρισμα επιπέδων σχημάτων Filter for type layers Φιλτράρισμα επιπέδων κειμένου Import Brushes… Εισαγωγή πινέλων… + Gradient Preset Options Επιλογές προεπιλογών ντεγκραντέ + Import .GRD Εισαγωγή .GRD + Choose a gradient file (.grd). Επιλέξτε αρχείο ντεγκραντέ (.grd). Import brushes (.abr) Εισαγωγή πινέλων (.abr) Mask All Απόκρυψη όλων με μάσκα Mask Options Επιλογές μάσκας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d3a61f70bb..e7f2385d5a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1148,6 +1148,9 @@ Ignore All Ignorar todo Image Size Tamaño de imagen Import Brushes… Importar pinceles… + Gradient Preset Options Opciones de ajustes preestablecidos de degradado + Import .GRD Importar .GRD + Choose a gradient file (.grd). Seleccione un archivo de degradados (.grd). Import brushes (.abr) Importar pinceles (.abr) Import Presets Importar ajustes preestablecidos Imported Importado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b1eb9c5e00..6076c41b5a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1130,6 +1130,9 @@ Ignore All Tout ignorer Image Size Taille de l'image Import Brushes… Importer des pinceaux… + Gradient Preset Options Options des paramètres prédéfinis de dégradé + Import .GRD Importer .GRD + Choose a gradient file (.grd). Choisissez un fichier de dégradés (.grd). Import brushes (.abr) Importer des pinceaux (.abr) Import Presets Importer des préréglages Imported Importé diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 626b84cd16..a7dd7723b7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3996,3 +3996,6 @@ cameraRaw Yellow Sárga Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás + Gradient Preset Options Színátmenet-előbeállítás beállításai + Import .GRD .GRD importálása + Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e29822449a..bb35e8cba7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1124,6 +1124,9 @@ Ignore All Abaikan Semua Image Size Ukuran Gambar Import Brushes… Impor Kuas… + Gradient Preset Options Opsi Preset Gradien + Import .GRD Impor .GRD + Choose a gradient file (.grd). Pilih file gradien (.grd). Import brushes (.abr) Impor kuas (.abr) Import Presets Impor Preset Imported Diimpor diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1c6ba0ec77..ca8e1cf717 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1147,6 +1147,9 @@ Ignore All Ignora tutto Image Size Dimensione immagine Import Brushes… Importa pennelli… + Gradient Preset Options Opzioni predefiniti sfumatura + Import .GRD Importa .GRD + Choose a gradient file (.grd). Scegli un file di sfumature (.grd). Import brushes (.abr) Importa pennelli (.abr) Import Presets Importa predefiniti Imported Importato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 761501e85c..7d0bf7dc47 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1149,6 +1149,9 @@ Ignore All すべて無視 Image Size 画像サイズ Import Brushes… ブラシを読み込み… + Gradient Preset Options グラデーションプリセットのオプション + Import .GRD .GRD を読み込み + Choose a gradient file (.grd). グラデーションファイル (.grd) を選択してください。 Import brushes (.abr) ブラシ (.abr) を読み込み Import Presets プリセットを読み込み Imported 読み込み済み diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b280416893..135a872867 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1118,6 +1118,9 @@ Ignore All 모두 무시 Image Size 이미지 크기 Import Brushes… 브러시 가져오기… + Gradient Preset Options 그레이디언트 사전 설정 옵션 + Import .GRD .GRD 가져오기 + Choose a gradient file (.grd). 그레이디언트 파일 (.grd)을 선택하세요. Import brushes (.abr) 브러시 가져오기(.abr) Import Presets 사전 설정 가져오기 Imported 가져옴 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index cf570d4021..6d8b52bf95 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1131,6 +1131,9 @@ Ignore All Alles negeren Image Size Afbeeldingsgrootte Import Brushes… Penselen importeren… + Gradient Preset Options Opties voor verloopvoorinstellingen + Import .GRD .GRD importeren + Choose a gradient file (.grd). Kies een verloopbestand (.grd). Import brushes (.abr) Penselen (.abr) importeren Import Presets Voorinstellingen importeren Imported Geïmporteerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cc0b77df70..7c89aa596d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1145,6 +1145,9 @@ Ignore All Ignoruj wszystko Image Size Rozmiar obrazu Import Brushes… Importuj pędzle… + Gradient Preset Options Opcje ustawień predefiniowanych gradientu + Import .GRD Importuj .GRD + Choose a gradient file (.grd). Wybierz plik gradientów (.grd). Import brushes (.abr) Importuj pędzle (.abr) Import Presets Importuj ustawienia wstępne Imported Zaimportowano diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bbe1fd8e7f..be6c0381f0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1127,6 +1127,9 @@ Ignore All Ignorar tudo Image Size Tamanho da imagem Import Brushes… Importar pincéis… + Gradient Preset Options Opções de predefinições de degradê + Import .GRD Importar .GRD + Choose a gradient file (.grd). Escolha um arquivo de degradês (.grd). Import brushes (.abr) Importar pincéis (.abr) Import Presets Importar predefinições Imported Importado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e4e0b0097..125155cadf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1018,6 +1018,9 @@ Rename Brush… Переименовать кисть… Delete Brush Удалить кисть Import Brushes… Импортировать кисти… + Gradient Preset Options Параметры наборов градиентов + Import .GRD Импортировать .GRD + Choose a gradient file (.grd). Выберите файл градиентов (.grd). Brush Name Имя кисти Brush Stroke Мазок кисти New Brush Preset… Новый набор параметров кисти… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 419c54c8ab..e4d4ec9aed 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3808,3 +3808,6 @@ cameraData Lens Lens Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın + Gradient Preset Options Gradyan Ön Ayarı Seçenekleri + Import .GRD .GRD İçe Aktar + Choose a gradient file (.grd). Bir gradyan dosyası (.grd) seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c5c724c92c..83f1250125 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -996,6 +996,9 @@ Rename Brush… Перейменувати пензель… Delete Brush Видалити пензель Import Brushes… Імпортувати пензлі… + Gradient Preset Options Параметри наборів градієнтів + Import .GRD Імпортувати .GRD + Choose a gradient file (.grd). Виберіть файл градієнтів (.grd). Brush Name Назва пензля Brush Stroke Мазок пензля New Brush Preset… Новий набір пензля… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6872d8cf6b..718278b5d2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3560,3 +3560,6 @@ 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 + 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). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index beb1b0a5cb..e2d5a3d6bf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1152,6 +1152,9 @@ Ignore All 全部忽略 Image Size 图像大小 Import Brushes… 导入画笔… + Gradient Preset Options 渐变预设选项 + Import .GRD 导入 .GRD + Choose a gradient file (.grd). 请选择渐变文件 (.grd)。 Import brushes (.abr) 导入画笔(.abr) Import Presets 导入预设 Imported 已导入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3772fc1c5..8543251b0c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1153,6 +1153,9 @@ Ignore All 全部略過 Image Size 影像尺寸 Import Brushes… 匯入筆刷… + Gradient Preset Options 漸層預設選項 + Import .GRD 匯入 .GRD + Choose a gradient file (.grd). 請選擇漸層檔案 (.grd)。 Import brushes (.abr) 匯入筆刷 (.abr) Import Presets 匯入預設集 Imported 已匯入 diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index e58961d9b5..26a84bd85f 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -24,6 +24,23 @@ pub fn is_preset_file(name: &str) -> bool { PRESET_EXTS.contains(&ext(name).as_str()) } +/// 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| { + if ext(&name) != "grd" { + return Err(tl!("Choose a gradient file (.grd).").into()); + } + open(app, &name, &bytes).ok_or_else(|| tl!("Choose a gradient file (.grd).").to_string())??; + Ok(Value::Null) + }); + if let Err(error) = result + && error != crate::file_dialog::CANCELLED + { + app.ui.status = error; + app.ui.status_error = true; + } +} + /// Command params for the file: a real path keeps the journal small; browser files arrive as bytes. fn source(name: &str, bytes: &[u8]) -> Value { #[cfg(not(target_arch = "wasm32"))] From 60c825513c7b6811b82f351f867dc46cb53c6f22 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:16:56 +0200 Subject: [PATCH 066/223] Type: every text edit respects Lock All (#4188) (#4189) #3132 refused type.edit on a Lock All layer, but type.insertText, edit.checkSpelling "change", Paste Lorem Ipsum (and the other type_extra_cmds::with_text edits) and Find and Replace Text "change" on a layer locked after the find still changed its text and recorded history. The check is now one pub(crate) helper, type_cmds::check_text_unlocked, called by every one of these paths with the same "...is locked" error as type.edit. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/edit_menu_cmds.rs | 1 + crates/engine/src/edit_menu_cmds/tests.rs | 21 +++++++++++++++++ crates/engine/src/type_cmds.rs | 18 +++++++++------ crates/engine/src/type_extra_cmds.rs | 5 ++++- crates/engine/src/type_extra_cmds/tests.rs | 15 +++++++++++++ crates/engine/src/type_spell_cmds.rs | 26 +++++++++++++++++++++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 4933ed93fc..734f7a1b52 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -894,6 +894,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { && let Some((id, a, b)) = cursor && text_of(&doc, id).is_some_and(|t| find_matches(&t, &find, case, whole).contains(&(a, b))) { + crate::type_cmds::check_text_unlocked(&doc, id)?; s.edit("Replace Text", |doc, _| { let snapshot = doc.clone(); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 0a1bab4633..1bede87251 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -446,6 +446,27 @@ fn find_and_replace_across_type_layers() { assert!(s.execute("edit.findAndReplaceText", json!({"find": ""})).is_err()); } +#[test] +fn find_and_replace_change_rejects_a_layer_locked_after_the_find() { + // #4188: the found match's layer gets Lock All before Change; the text must stay. + let mut s = session(8); + let id = s.execute("type.create", json!({"text": "abc", "x": 2, "y": 12})).unwrap()["layer"].as_u64().unwrap(); + // A second, unlocked type layer keeps the command enabled. + s.execute("type.create", json!({"text": "zzz", "x": 2, "y": 40})).unwrap(); + let found = s.execute("edit.findAndReplaceText", json!({"find": "abc", "action": "find"})).unwrap(); + assert_eq!(found["found"]["layer"].as_u64(), Some(id)); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let result = s.execute("edit.findAndReplaceText", json!({"find": "abc", "replace": "x", "action": "change"})); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + let text = match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Text(t) => t.text.clone(), + _ => String::new(), + }; + assert_eq!(text, "abc"); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + #[test] fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { // #703: `allLayers: false` with a raster layer active leaves nothing to search. diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4a4cb347bf..a53b6baba4 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -466,17 +466,21 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) { t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } +/// Refuses to change a type layer under Lock All (its own or an enclosing group's), with +/// Photoshop's message. Every command that edits a type layer's text calls it first (#3132, #4188). +pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if doc.effective_locks(id).all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); + } + Ok(()) +} + /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; - { - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if doc.effective_locks(id).all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); - } - } + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index e460a975b6..a11db9976b 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -14,7 +14,9 @@ use photocraft_text::render::PathEl; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets}; +use crate::type_cmds::{ + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, +}; use crate::{EngineError, Result, Session}; /// Photoshop's Paste Lorem Ipsum filler. @@ -67,6 +69,7 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { /// Edits a type layer as one history step and re-renders it. fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { let id = layer_param(s, p)?; + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 993ae41e8d..395128a440 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -282,3 +282,18 @@ fn rasterize_type_layer() { assert!(matches!(s.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Raster(_))); assert!(!s.is_enabled("type.rasterizeTypeLayer")); } + +#[test] +fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.setProps", json!({"layer": id.0, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for params in [json!({}), json!({"layer": id.0})] { + let result = s.execute("type.pasteLoremIpsum", params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + assert_eq!(text(&s, id).text, "Hi"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index cbebb0664d..12a63a364c 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -94,6 +94,7 @@ fn change(s: &mut Session, p: &Value) -> Result { let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string(); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let (a, b) = (byte_at(t, start), byte_at(t, end.max(start))); if let Some(w) = p.get("word").and_then(Value::as_str) && &t[a..b] != w @@ -199,6 +200,7 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let new = norm_text(&new); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let n = t.chars().count(); let (a, b) = match (p.get("range").and_then(Value::as_array), p.get("at").and_then(Value::as_u64)) { (Some(r), _) if r.len() == 2 => { @@ -272,6 +274,28 @@ mod tests { text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string() } + #[test] + fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not these two text edits. + let (mut s, id) = session("Teh"); + // A second, unlocked type layer keeps Check Spelling enabled. + s.execute("type.create", json!({"x": 10, "y": 90, "text": "zzz", "size": 12})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for (cmd, params) in [ + ("type.insertText", json!({"layer": id, "text": "b"})), + ("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})), + ] { + let result = s.execute(cmd, params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + assert_eq!(text_of(&s, id), "Teh", "{cmd}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{cmd}"); + } + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.insertText", json!({"layer": id, "text": "b"})).unwrap(); + assert_eq!(text_of(&s, id), "Tehb"); + } + #[test] fn lists_changes_and_learns_words() { let (mut s, id) = session("Teh quick brwon fox, teh end. Photocraft"); From 71827a614cf8238d8f4e982c724e04f68b7b7afc Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:01 +0200 Subject: [PATCH 067/223] Color Sampler: keep the CMYK readout in range on 32-bit documents (#4191) (#4192) 32-bit samples can lie outside 0..1. The RGB readout clamped them, but device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before the conversion; this fixes both the Color Sampler and the Info panel. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/sampler_cmds.rs | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 38c355071c..472ecb672a 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -24,8 +24,11 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. +/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. 32-bit samples can +/// lie outside 0..1, so each channel is clamped first (NaN reads as 0), like the RGB readout. pub fn device_cmyk(r: f32, g: f32, b: f32) -> [i32; 4] { + let unit = |v: f32| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + let (r, g, b) = (unit(r), unit(g), unit(b)); let k = 1.0 - r.max(g).max(b); let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; [f(r), f(g), f(b), k].map(|v| (v * 100.0).round() as i32) @@ -418,6 +421,36 @@ mod tests { assert_eq!(device_cmyk(0.5, 0.5, 0.5), [0, 0, 0, 50]); } + /// 32-bit samples can lie outside 0..1 (or be NaN); the CMYK readout clamps them like the RGB + /// readout does instead of reporting K:-50% or C:120% (#4191). + #[test] + fn device_cmyk_clamps_out_of_range_and_nan_samples() { + assert_eq!(device_cmyk(1.5, 1.5, 1.5), [0, 0, 0, 0]); + assert_eq!(device_cmyk(2.0, 0.0, 0.0), [0, 100, 100, 0]); + let in_range = |c: [i32; 4]| c.iter().all(|v| (0..=100).contains(v)); + for (r, g, b) in [(-0.2, 0.5, 0.5), (-1.0, -1.0, -1.0), (f32::NAN, 0.5, 0.5), (f32::NAN, f32::NAN, f32::NAN), (f32::INFINITY, f32::NEG_INFINITY, 0.5)] { + let c = device_cmyk(r, g, b); + assert!(in_range(c), "{r} {g} {b} -> {c:?}"); + } + assert_eq!(device_cmyk(f32::NAN, f32::NAN, f32::NAN), [0, 0, 0, 100]); + } + + #[test] + fn hdr_pixel_on_a_32_bit_document_reads_in_range() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let hdr = photocraft_raster::from_rgba(&surf.format(), [2.0, 2.0, 2.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &hdr); + Ok(()) + }) + .unwrap(); + let r = s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + assert_eq!(r["point"]["rgb"], json!([255, 255, 255])); + assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 0])); + } + #[test] fn points_work_at_every_depth_and_mode() { for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 8), ("cmyk", 8), ("lab", 8)] { From 56869d12e3804c9e556c92a09964cfb925d04ab0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:06 +0200 Subject: [PATCH 068/223] Layer Comps: the warning icon clears only its own comp (#4197) (#4198) The warning triangle on a comp row sent layerComp.updateWarnings {"clear":true}, which dropped the stale states of every comp. The command now takes an optional "comp" (id or name) that limits both the report and the clear to that comp; an unknown comp is an error and changes nothing. Without "comp" it still acts on every comp (Clear All Layer Comp Warnings). The row icon sends its own comp id. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/comps_cmds.rs | 17 ++++++++--- crates/engine/src/comps_cmds/tests.rs | 43 +++++++++++++++++++++++++++ crates/ui-egui/src/comps_ui.rs | 31 ++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index 58f92f84ea..fe5708387c 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -319,13 +319,21 @@ fn restore_last(s: &mut Session) -> Result { } /// Comps that refer to deleted layers (Photoshop's warning triangle). `"clear": true` drops -/// those stale states (Clear Layer Comp Warning) as one history step. +/// those stale states as one history step: from `"comp"` only when given (Clear Layer Comp +/// Warning), else from every comp (Clear All Layer Comp Warnings). `"comp"` also limits the +/// report to that comp; an unknown comp is an error and changes nothing. fn update_warnings(s: &mut Session, p: &Value) -> Result { + let cmd = "layerComp.updateWarnings"; let d = s.active().ok_or(EngineError::NoDocument)?; + let only = match p.get("comp") { + None | Some(Value::Null) => None, + Some(_) => Some(comp_param(&d.doc, p, cmd)?), + }; let warnings: Vec = d .doc .layer_comps .iter() + .filter(|c| only.is_none_or(|id| c.id == id)) .filter_map(|c| { let missing = c.missing_layers(&d.doc); (!missing.is_empty()).then(|| json!({"comp": c.id, "name": c.name, "missingLayers": missing.iter().map(|l| l.0).collect::>()})) @@ -333,9 +341,10 @@ fn update_warnings(s: &mut Session, p: &Value) -> Result { .collect(); let n = warnings.len(); if flag(p, "clear", false) && n > 0 { - s.edit("Clear Layer Comp Warnings", |doc, _| { + let label = if only.is_some() { "Clear Layer Comp Warning" } else { "Clear All Layer Comp Warnings" }; + s.edit(label, |doc, _| { let live: std::collections::HashSet = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); - for c in &mut doc.layer_comps { + for c in doc.layer_comps.iter_mut().filter(|c| only.is_none_or(|id| c.id == id)) { c.states.retain(|st| live.contains(&st.layer)); } Ok(()) @@ -434,7 +443,7 @@ pub fn specs() -> Vec { "layerComp.updateWarnings", "Layer Comp Warnings", &[], - r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, + r##"{"comp":id|name? (default: every comp; limits the report and the clear to that comp),"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, has_doc, update_warnings ), diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 22ef1a4697..5458523bcf 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -304,3 +304,46 @@ fn unreachable_comp_positions_leave_the_layer_in_place() { assert_eq!(pos(&s, b), Some((31, 22))); } } + +#[test] +fn clearing_one_comps_warning_leaves_the_others() { + // Regression for #4197: the per-comp warning icon cleared every comp's warnings. + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap(); + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + // Reporting can be limited to one comp too. + let w = s.execute("layerComp.updateWarnings", json!({"comp": c2})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": c1})).unwrap(); + assert!(doc(&s).comp(c1 as u32).unwrap().missing_layers(doc(&s)).is_empty()); + assert_eq!(doc(&s).comp(c2 as u32).unwrap().missing_layers(doc(&s)), vec![a]); + let w = s.execute("layerComp.updateWarnings", json!({})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + // Without "comp" every comp is cleared (Clear All Layer Comp Warnings). + s.execute("layerComp.updateWarnings", json!({"clear": true})).unwrap(); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 0); +} + +#[test] +fn clearing_warnings_of_an_unknown_comp_is_an_error() { + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + let before = doc(&s).layer_comps.clone(); + for bad in [json!(c1 + 99), json!("nope"), json!(-1), json!(1.5), json!(true), json!([c1])] { + assert!(s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": bad})).is_err(), "{bad}"); + } + assert_eq!(doc(&s).layer_comps, before); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); +} diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index d6dc3e8d82..30304f6c82 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -18,6 +18,12 @@ pub fn target(app: &PhotocraftApp) -> Option { app.ui.layer_comp_selected.filter(|id| doc.comp(*id).is_some()).or(doc.last_applied_comp.filter(|id| doc.comp(*id).is_some())) } +/// The row's warning triangle: Clear Layer Comp Warning for that comp only (Photoshop keeps +/// Clear All Layer Comp Warnings as a separate command). +fn clear_warning_action(comp: u32) -> (&'static str, Value) { + ("layerComp.updateWarnings", json!({"clear": true, "comp": comp})) +} + fn run(app: &mut PhotocraftApp, id: &str, p: Value) { if let Err(e) = app.run(id, p) { app.ui.status = e; @@ -124,7 +130,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { )); if resp.clicked() { toggle_hit = true; - action = Some(("layerComp.updateWarnings", json!({"clear": true}))); + action = Some(clear_warning_action(c.id)); } } name_x += 18.0; @@ -185,3 +191,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_engine::Session; + + #[test] + fn warning_icon_clears_only_its_own_comp() { + // Regression for #4197. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let layer = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap() as u32; + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layer.delete", json!({"layer": layer})).unwrap(); + let (id, p) = clear_warning_action(c1); + assert_eq!(p["comp"], c1); + s.execute(id, p).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(doc.comp(c1).unwrap().missing_layers(doc).is_empty()); + assert_eq!(doc.comp(c2).unwrap().missing_layers(doc).iter().map(|l| l.0).collect::>(), vec![layer]); + } +} From 63a0e43e74628769c4341d6fa0dcf68b9a6377a4 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:17:10 -0400 Subject: [PATCH 069/223] fix(type): key font menu caches on the shared engine's fonts (#4200) The font menu's preview samples, family list and localized names were cached on fonts::generation(), which any FontDb moves, and the previews also on the served catalog's generation. Other engines registering fonts (tests running in parallel) re-rendered every visible sample each pass, and families covering Arabic asked for a repaint, so Harness::run never settled: options_font_family_preserves_peer_faces_and_batches_layer_flags and the font picker tests failed intermittently with "exceeded max_steps". - photocraft-text: fonts::shared_generation(), moved only by the shared engine's FontDb (FontDb::mark_shared from new_shared). - ui-egui: families(), localized_families() and the preview cache key on it; previews no longer key on the served catalog (a served family's sample appears when its fonts register, which moves the generation). Co-authored-by: Claude Opus 5.5 --- crates/text/src/fonts.rs | 23 +++++++++++++++++++++++ crates/text/src/lib.rs | 4 +++- crates/text/src/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/font_preview.rs | 11 +++++++---- crates/ui-egui/src/type_tool.rs | 8 ++++---- crates/ui-egui/src/type_tool_tests.rs | 27 +++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 21fc1a92a4..433baee074 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -109,6 +109,16 @@ pub fn generation() -> u64 { GENERATION.load(Ordering::Relaxed) } +/// Bumped only by the [`FontDb`] of [`crate::shared`], so caches of the shared engine's fonts +/// aren't invalidated by unrelated databases (a document's own engine, a test's throwaway one). +static SHARED_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Changes whenever font data is registered in the shared engine's [`FontDb`] ([`crate::shared`]). +/// Font menus key their caches on this, not [`generation`]. +pub fn shared_generation() -> u64 { + SHARED_GENERATION.load(Ordering::Relaxed) +} + /// One face in the database (for font menus). #[derive(Clone, Debug, PartialEq)] pub struct FaceInfo { @@ -145,6 +155,8 @@ pub struct FontDb { missing_glyph_protection: bool, face_cache: HashMap>, face_aliases: HashMap<(String, usize), String>, + /// This is the shared engine's database: registrations also move [`shared_generation`]. + shared: bool, } impl Default for FontDb { @@ -165,6 +177,7 @@ impl FontDb { missing_glyph_protection: true, face_cache: HashMap::new(), face_aliases: HashMap::new(), + shared: false, }; for (_, bytes) in BUNDLED { db.register_static_font(bytes.as_slice()); @@ -246,9 +259,19 @@ impl FontDb { self.face_cache.clear(); self.refresh_generics(); GENERATION.fetch_add(1, Ordering::Relaxed); + if self.shared { + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } names } + /// Marks this as the shared engine's database ([`crate::shared`]) and moves + /// [`shared_generation`], since its fonts replace whatever caches saw before. + pub(crate) fn mark_shared(&mut self) { + self.shared = true; + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } + fn refresh_generics(&mut self) { let c = &mut self.fcx.collection; if let Some(inter) = c.family_id(DEFAULT_FAMILY) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index da323b5a22..35e7dccbdf 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -147,7 +147,9 @@ fn uses_system_fonts() -> bool { } fn new_shared() -> std::sync::Mutex { - std::sync::Mutex::new(if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }) + let mut engine = if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }; + engine.fonts.mark_shared(); + std::sync::Mutex::new(engine) } /// A process-wide engine for callers without their own (bundled fonts only on the web; bundled diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ae3092ca46..d502385f07 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -47,6 +47,21 @@ fn registering_fonts_moves_the_generation() { assert!(fonts::generation() > g); } +/// Font menus cache on the shared engine's generation: other engines registering fonts (a test's +/// throwaway one, in parallel) must not invalidate them, or the menus re-render every frame. +#[test] +fn only_the_shared_database_moves_the_shared_generation() { + let _ = crate::shared(); + let g = fonts::shared_generation(); + TextEngine::new().fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert_eq!(fonts::shared_generation(), g, "an unrelated engine leaves it alone"); + let mut e = TextEngine::new(); + e.fonts.mark_shared(); + let g = fonts::shared_generation(); + e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert!(fonts::shared_generation() > g); +} + #[test] fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() { let mut e = TextEngine::new(); diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 1568abf655..74c334d682 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -49,9 +49,12 @@ pub(crate) fn sample(preview: FontPreview) -> Option<(f32, [f32; 2])> { Some((size, box_for(size))) } -/// Family, display scale, the font database generations (so a family installed or fetched -/// (served fonts) after its first miss gets a sample once it is available) and the point size. -type Key = (String, u32, (u64, u64), u32); +/// Family, display scale, the shared font database's generation (so a family installed or +/// fetched (served fonts) after its first miss gets a sample once it is available) and the point +/// size. Not the served catalog's generation: a listed family has no sample until its fonts are +/// registered, which moves the database's generation, and keying on the catalog re-rendered +/// every sample whenever it changed. +type Key = (String, u32, u64, u32); #[derive(Clone)] struct Samples { latin: TextureHandle, @@ -111,7 +114,7 @@ fn images(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32, si fn key(ctx: &egui::Context, family: &str, size: f32) -> Key { let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; - (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation()), size as u32) + (family.to_owned(), scale, photocraft_text::fonts::shared_generation(), size as u32) } /// Width a row reserves for its samples: wider once a painted row showed the family covers diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 4bfa31227c..6a801b3385 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -985,11 +985,11 @@ fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor } /// Font families: bundled, system, and those the host serves (`photocraft_text::served`, fetched -/// when picked). Cached until fonts are registered or the served catalog changes. +/// when picked). Cached until the shared engine registers fonts or the served catalog changes. pub fn families() -> Arc> { type Cached = Option<((u64, u64), Arc>)>; static FAMILIES: std::sync::Mutex = std::sync::Mutex::new(None); - let key = (photocraft_text::fonts::generation(), photocraft_text::served::generation()); + let key = (photocraft_text::fonts::shared_generation(), photocraft_text::served::generation()); let mut cached = FAMILIES.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key @@ -1027,11 +1027,11 @@ pub fn sync_font_names(ctx: &egui::Context, show_in_english: bool) { }); } -/// The fonts' localized family names, keyed by family. Cached until fonts are registered. +/// The fonts' localized family names, keyed by family. Cached until the shared engine registers fonts. fn localized_families() -> Arc> { type Cached = Option<(u64, Arc>)>; static LOCALIZED: std::sync::Mutex = std::sync::Mutex::new(None); - let key = photocraft_text::fonts::generation(); + let key = photocraft_text::fonts::shared_generation(); let mut cached = LOCALIZED.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 25011745e2..046fd00f6f 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1428,6 +1428,33 @@ mod multi_layer_formatting { assert_ne!(text(h.state(), ids[2]).antialias, AntiAlias::Sharp); } + /// Other engines registering fonts and the served catalog growing (tests running in parallel) + /// used to move the key of the font menu's preview cache, so its samples re-rendered and asked + /// for a repaint every pass and `Harness::run` never settled (a flaky `exceeded max_steps`). + /// Here both happen on every pass. + #[test] + fn the_font_menu_settles_while_other_engines_register_fonts() { + let (app, _) = fixture(); + let mut pass = 0u32; + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).build_ui_state( + move |ui, app| { + photocraft_text::TextEngine::new(); + // The served catalog is process-wide: names that sort last. + photocraft_text::served::add_families([format!("zz Churn Test {pass:03}")]); + pass = pass.saturating_add(1); + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::super::options_bar(app, ui)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.run(); + h.run(); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); From e4f15eecdfbb8bc2add8a37c00a6c199c963c80d Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:14 +0200 Subject: [PATCH 070/223] Vector masks: validate feather like pixel masks (#4201) (#4202) layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became inf (hard edge, null in info, .pcraft save refused) and any huge value was accepted. It now takes the same finite 0..=1000 px range and error as layer.layerMask.edit, checked before the edit so a bad value leaves the mask and history untouched. The params doc says so. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/vector_cmds.rs | 18 +++++++++++++++--- crates/engine/src/vector_cmds/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index b732ff2629..e32a7e41fd 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1415,6 +1415,16 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.vectorMask.edit"; let id = layer_id(s, p)?; let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?; + // Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201). + let feather = p + .get("feather") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .map(|n| n as f32) + .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) + }) + .transpose()?; s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1430,8 +1440,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(d) = f64p(p, "density") { vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; } - if let Some(f) = f64p(p, "feather") { - vm.feather = f.max(0.0) as f32; + if let Some(f) = feather { + vm.feather = f; } if let Some(b) = p.get("invert").and_then(Value::as_bool) && b @@ -1821,7 +1831,9 @@ pub fn specs() -> Vec { "layer.vectorMask.edit", "Edit Vector Mask", [], - leak(format!(r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":px?,"invert":true?}}. {PATH_FORM}"##)), + leak(format!( + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + )), has_layer, vector_mask_edit ), diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 00cb58d8cb..11a4b511e0 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -701,6 +701,28 @@ fn vector_mask_commands_and_compositing() { assert!(s.execute("layer.vectorMask.revealAll", json!({})).is_err()); } +/// #4201: the vector mask's feather takes the pixel mask's range (finite 0..=1000 px); anything +/// else is refused before the edit, so no infinite feather can block a .pcraft save. +#[test] +fn vector_mask_edit_rejects_out_of_range_feather() { + let mut s = session(32, 32, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.vectorMask.revealAll", json!({})).unwrap(); + let feather = |s: &Session| doc(s).layer(id).unwrap().vector_mask.as_ref().unwrap().feather; + let steps = s.active().unwrap().history.past_len(); + for bad in [json!(1e39), json!(-1), json!(1001), json!("x"), json!(null)] { + assert!(s.execute("layer.vectorMask.edit", json!({"feather": bad})).is_err(), "feather {bad}"); + assert_eq!(feather(&s), 0.0, "feather {bad}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "feather {bad}"); + } + let r = s.execute("layer.vectorMask.edit", json!({"feather": 12.5})).unwrap(); + assert_eq!(r["feather"], 12.5); + assert_eq!(feather(&s), 12.5); + s.execute("layer.vectorMask.edit", json!({"feather": 1000})).unwrap(); + assert_eq!(feather(&s), 1000.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); From 72c8f58e11dff989ea95d8960fb9947e5ae2d26e Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:38 +0200 Subject: [PATCH 071/223] Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204) rounded_rect took the absolute width/height without moving the origin, so shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and (y, h) for negative sizes instead. The Shape tool drag already normalises its rect, so interactive drawing is unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/vector_cmds/tests.rs | 25 +++++++++++++++++++++++++ crates/vector/src/shapes.rs | 20 ++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 11a4b511e0..21207d3e96 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -796,6 +796,31 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +#[test] +fn shape_create_negative_size_rect_covers_the_ellipse_box() { + // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and + // polygon with the same `rect` spanned 30..50. + for depth in [8, 16, 32] { + let mut s = session(100, 60, depth); + let ell = s.execute("shape.create", json!({"kind": "ellipse", "rect": [50, 10, -20, 30], "fill": "#0000ff"})).unwrap()["bounds"].clone(); + assert_eq!(ell, json!([30, 10, 20, 30]), "{depth}-bit"); + for params in [ + json!({"kind": "rect", "rect": [50, 10, -20, 30], "fill": "#0000ff"}), + json!({"kind": "roundedRect", "rect": [50, 10, -20, 30], "radii": 5, "fill": "#0000ff"}), + json!({"kind": "rect", "rect": [50, 40, -20, -30], "fill": "#0000ff"}), + ] { + let r = s.execute("shape.create", params.clone()).unwrap(); + assert_eq!(r["bounds"], ell, "{params} at {depth}-bit"); + let sh = shape(&s, r["layer"].as_u64().unwrap()); + let c = sh.cache.as_ref().unwrap(); + let fmt = c.format(); + let alpha = |x: i32, y: i32| photocraft_raster::to_rgba(&fmt, &c.read_region(photocraft_doc::Rect::from_xywh(x, y, 1, 1)))[3]; + assert!(alpha(40, 25) > 0.99, "{params} at {depth}-bit"); + assert!(alpha(60, 25) < 0.01, "{params} at {depth}-bit"); + } + } +} + /// Two overlapping 40×40 rectangle shape layers ("Rectangle 1" black below, "Rectangle 2" grey /// above, overlapping by 20×20) with both selected, like the Layers panel in #2400. fn two_selected_rects(depth: u32) -> (Session, u64, u64) { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index a459ba816e..b154a47325 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -29,9 +29,11 @@ pub fn clamp_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] { r.map(|v| v * f) } -/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. +/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. A negative +/// width or height extends from the origin to the left or up, like [`ellipse`] and [`polygon`]. pub fn rounded_rect(x: f64, y: f64, w: f64, h: f64, radii: [f64; 4]) -> Path { - let (w, h) = (w.abs(), h.abs()); + 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) }; let [tl, tr, br, bl] = clamp_radii(w, h, radii); if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 { return rect(x, y, w, h); @@ -143,6 +145,20 @@ mod tests { assert_eq!(clamp_radii(10.0, 10.0, [10.0; 4]), [5.0; 4]); } + #[test] + fn negative_size_rect_extends_from_its_origin_like_the_ellipse() { + // Issue #4203: a negative width/height drew the rectangle on the far side of its origin. + let want = ellipse(50.0, 10.0, -20.0, 30.0).control_bounds().unwrap(); + let want_neg_h = ellipse(50.0, 40.0, -20.0, -30.0).control_bounds().unwrap(); + for radii in [[0.0; 4], [5.0; 4]] { + let b = rounded_rect(50.0, 10.0, -20.0, 30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want, "radii {radii:?}"); + assert_eq!(b, (30.0, 10.0, 50.0, 40.0), "radii {radii:?}"); + let b = rounded_rect(50.0, 40.0, -20.0, -30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want_neg_h, "radii {radii:?}"); + } + } + #[test] fn star_vertices() { assert_eq!(polygon(0.0, 0.0, 10.0, 10.0, 5, 0.5).subpaths[0].knots.len(), 10); From a0e9ee213d86396601075e222988cfdb565ccb7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:41 +0200 Subject: [PATCH 072/223] Cryptomatte: decode the .blue/.alpha rank of every channel set (#4208) (#4209) Each Cryptomatte RGBA set stores two (ID, coverage) ranks, (red, green) and (blue, alpha), but only red/green were read, so every second rank was lost and mattes got holes at edges and overlaps. Suffixes were also matched case-sensitively, missing Blender's .R/.G/.B/.A. Stream channels now carry a rank (0 red/green, 1 blue/alpha), suffixes match in any case, and decode stores each channel at slot*2+rank (bounds-checked), still sorted by coverage. The allocation check counts four channels per set. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/codecs/src/codecs/exr_cryptomatte.rs | 36 +++++++++------ crates/codecs/tests/exr_cryptomatte.rs | 50 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 2fabd0c6c5..5f13f98621 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -1,7 +1,8 @@ //! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR //! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are //! announced by `cryptomatte//name|hash|conversion|manifest` header attributes; the -//! samples live in `NN.red` (ID) / `NN.green` (coverage) channel pairs. +//! samples live in `NN` RGBA channel sets, each holding two (ID, coverage) ranks: +//! `.red`/`.green` (rank 2·NN) and `.blue`/`.alpha` (rank 2·NN+1). //! //! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its //! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32 @@ -20,21 +21,26 @@ use crate::options::Limits; const F: Format = Format::OpenExr; /// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name -/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index, -/// and whether it is the coverage (green) channel instead of the ID (red). +/// (`crypto_asset00`), the stream index, whether it is a coverage channel (green/alpha) +/// instead of an ID (red/blue), and its rank within the set (0 for red/green, 1 for +/// blue/alpha). pub(crate) struct StreamChannel { pub stream: String, pub index: u32, pub coverage: bool, + pub rank: usize, } /// `NN` where NN is one or more digits (the reference readers match `\d+`), with a -/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix. +/// `.red`/`.r` or `.blue`/`.b` (ID) or `.green`/`.g` or `.alpha`/`.a` (coverage) suffix, +/// in any case (Blender writes `.R`/`.G`/`.B`/`.A`). pub(crate) fn split_stream(channel: &str) -> Option { let (stream, suffix) = channel.rsplit_once('.')?; - let coverage = match suffix { - "red" | "r" => false, - "green" | "g" => true, + let (coverage, rank) = match suffix.to_ascii_lowercase().as_str() { + "red" | "r" => (false, 0), + "green" | "g" => (true, 0), + "blue" | "b" => (false, 1), + "alpha" | "a" => (true, 1), _ => return None, }; let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count(); @@ -43,7 +49,7 @@ pub(crate) fn split_stream(channel: &str) -> Option { } let split = stream.len() - digits; let index = stream[split..].parse().ok()?; - Some(StreamChannel { stream: stream.to_string(), index, coverage }) + Some(StreamChannel { stream: stream.to_string(), index, coverage, rank }) } /// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby). @@ -210,14 +216,17 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?; let too_large = || CodecError::malformed(F, "the layer is too large"); let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?); - // Each pixel also gets a pair list, so count its header with the channel samples. - let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::>() as u64); + // Each pixel also gets a pair list, so count its header with the channel samples (four + // f32 channels per set: two ranks of ID and coverage). + let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64); limits.check_bytes(w32, h32, per_pixel)?; let ex_layer = super::exr::read_layer(bytes, layer.part)?; - let mut ids: Vec> = vec![Vec::new(); layer.channels.len()]; - let mut coverages: Vec> = vec![Vec::new(); layer.channels.len()]; + // Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`. + let ranks = layer.channels.len().saturating_mul(2); + let mut ids: Vec> = vec![Vec::new(); ranks]; + let mut coverages: Vec> = vec![Vec::new(); ranks]; for c in &ex_layer.channel_data.list { let Some(stream) = split_stream(&c.name.to_string()) else { continue }; let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue }; @@ -226,7 +235,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer")); } let bucket = if stream.coverage { &mut coverages } else { &mut ids }; - bucket[slot] = values; + let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) else { continue }; + *dst = values; } let mut pixels = vec![Vec::new(); npx]; diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/exr_cryptomatte.rs index f5f76c3adb..28ad71e191 100644 --- a/crates/codecs/tests/exr_cryptomatte.rs +++ b/crates/codecs/tests/exr_cryptomatte.rs @@ -164,6 +164,56 @@ fn short_channel_suffixes_decode_too() { assert_eq!(buf.at(3, 3), &[(cube, 1.0)]); } +/// A 4x4 layer `crypto` of one RGBA set `crypto00.///` with the given suffixes: +/// rank 0 (red/green) holds `cube` at 0.6 everywhere, rank 1 (blue/alpha) holds `sphere` +/// at 0.4 everywhere, the second (ID, coverage) pair the specification stores per set. +fn cryptomatte_file_rgba(suffixes: [&str; 4]) -> Vec { + let (w, h) = (4usize, 4usize); + let [r, g, b, a] = suffixes; + let mk = |s: &str, v: f32| AnyChannel::new(format!("crypto00.{s}").as_str(), FlatSamples::F32(vec![v; w * h])); + let list = vec![mk(r, cryptomatte_id("cube")), mk(g, 0.6), mk(b, cryptomatte_id("sphere")), mk(a, 0.4)]; + let key = cryptomatte_key("crypto"); + let mut attrs = LayerAttributes::named("crypto"); + attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("crypto"))); + let layer = Layer::new( + (w, h), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes"); + out.into_inner() +} + +#[test] +fn blue_alpha_rank_of_a_set_decodes_too() { + // Each RGBA set holds two ranks: (red, green) and (blue, alpha). Both must come back. + let bytes = cryptomatte_file_rgba(["red", "green", "blue", "alpha"]); + let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].channels, ["crypto00"], "one set, listed once"); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + for y in 0..4 { + for x in 0..4 { + assert_eq!(buf.at(x, y), &[(cube, 0.6), (sphere, 0.4)], "({x}, {y})"); + } + } +} + +#[test] +fn uppercase_channel_suffixes_decode_both_ranks() { + // Blender and others write `.R/.G/.B/.A`; suffixes match regardless of case. + for suffixes in [["R", "G", "B", "A"], ["Red", "Green", "Blue", "Alpha"], ["r", "g", "b", "a"]] { + let bytes = cryptomatte_file_rgba(suffixes); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + assert_eq!(buf.at(2, 1), &[(cube, 0.6), (sphere, 0.4)], "{suffixes:?}"); + } +} + #[test] fn unknown_layer_name_is_unsupported() { let bytes = cryptomatte_file("crypto_asset"); From 8029b061af2e8be76975fb7828cd0ef19b2c4c85 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:45 +0200 Subject: [PATCH 073/223] EXR: an unnamed part can be opened by the name the part list gives it (#4210) (#4211) file.exrParts lists a part without a name attribute as "part ", but file.openExrParts only matched parts whose name attribute equalled the requested name, so "part/part 0" failed with "no flat part named part 0". One helper now gives the display name for the listing, the lookup and the layer name; a named part still wins over an unnamed one with the same fallback name. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/exr_cmds.rs | 40 +++++++++++++++++++++++++++++++++++ crates/io/src/exr_parts.rs | 20 ++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index c0a3310f9f..908942d4fd 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -210,6 +210,46 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A single-part EXR with no part name (the usual render output) plus an AOV group. + fn write_unnamed_grouped(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let list = vec![mk("R", 0.7), mk("G", 0.7), mk("B", 0.7), mk("diffuse.R", 0.3), mk("diffuse.G", 0.3), mk("diffuse.B", 0.3)]; + let layer = Layer::new( + (w, h), + LayerAttributes::default(), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + #[test] + fn an_unnamed_part_opens_by_the_name_the_listing_gives_it() { + // #4210: the listing names an unnamed part "part 0"; feeding that name back must open it. + let dir = std::env::temp_dir().join(format!("pcraft-exr-unnamed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("unnamed.exr"); + write_unnamed_grouped(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let part = info.as_array().unwrap().iter().find(|e| e["kind"] == "part").expect("a part entry"); + let name = part["name"].as_str().unwrap(); + assert_eq!(name, "part 0", "{info}"); + let key = format!("part/{name}"); + let mut s = session(); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), key: true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "part 0"); + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + assert!((px[0] - 0.7).abs() < 1e-4, "{:?}", px); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() { let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id())); diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 93b8e85f36..5474d00310 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -98,6 +98,12 @@ fn channel_base_split(name: &str) -> Option<(&str, &str)> { (!prefix.is_empty()).then_some((prefix, base)) } +/// The name a part is listed, selected and imported as: its `name` attribute, or +/// `part ` when it has none (a single-part render usually carries no name). +fn part_display_name(part: &codecs::ExrPartInfo) -> String { + part.name.clone().unwrap_or_else(|| format!("part {}", part.index)) +} + /// Lists the entries of a multi-part or channel-grouped EXR for a chooser: flat parts in /// file order, then the channel groups, then the Cryptomatte layers as information. A file /// whose parts are all deep is refused (the deep decoder composites those). @@ -113,7 +119,7 @@ pub fn entries(bytes: &[u8]) -> Result, IoError> { } out.push(Entry { kind: EntryKind::Part, - name: part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), + name: part_display_name(part), view: part.view.clone(), width: part.width, height: part.height, @@ -186,8 +192,14 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result| view.as_ref().is_none_or(|v| sel.view.eq_ignore_ascii_case("both") || v.eq_ignore_ascii_case(&sel.view)); - let part_by_name = - |want: &str| info.iter().find(|p| !p.deep && p.name.as_deref() == Some(want)).ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))); + // A named part wins over an unnamed one listed under the same fallback name ("part 0"). + let part_by_name = |want: &str| { + let flat = || info.iter().filter(|p| !p.deep); + flat() + .find(|p| p.name.as_deref() == Some(want)) + .or_else(|| flat().find(|p| p.name.is_none() && part_display_name(p) == want)) + .ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))) + }; let mut chosen_parts: Vec<&codecs::ExrPartInfo> = Vec::new(); for want in &sel.parts { let p = part_by_name(want)?; @@ -235,7 +247,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result Date: Tue, 13 Oct 2026 03:17:49 +0200 Subject: [PATCH 074/223] Bitmap mode: validate the halftone screen angle (#4214) (#4215) Image > Mode > Bitmap (halftone) passed its angle through unchecked: 1e39 became inf as f32, the screen rotation turned it into NaN and an almost white bitmap was recorded in history; 500 was accepted although the params doc (and Photoshop's Halftone Screen dialog) says -180..180. The angle must now be a finite number in -180..=180 and the frequency a finite number (still clamped to 1..999 lines/inch); otherwise the command returns BadParams before touching the document. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/mode_cmds.rs | 17 +++++++++++++-- crates/engine/src/mode_cmds/tests.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 319a6e5334..fe22a192c7 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -27,6 +27,14 @@ fn num(p: &Value, key: &str, default: f32) -> f32 { p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } +/// A finite number param, `default` when absent or null; anything else is a bad-params error. +fn finite_num(p: &Value, cmd: &str, key: &str, default: f64) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number"))), + } +} + fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { p.get(key).and_then(Value::as_str).unwrap_or(default) } @@ -388,9 +396,14 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { "pattern" => BitmapMethod::Pattern, "diffusion" => BitmapMethod::Diffusion, "halftone" => { - let freq = num(p, "frequency", 53.0).clamp(1.0, 999.0); + // Photoshop's Halftone Screen: frequency 1..999 lines/inch, angle -180..180 degrees. + let freq = finite_num(p, CMD, "frequency", 53.0)?.clamp(1.0, 999.0) as f32; + let angle = finite_num(p, CMD, "angle", 45.0)?; + if !(-180.0..=180.0).contains(&angle) { + return Err(bad(CMD, format!("angle {angle} is outside -180..180"))); + } let shape = HalftoneShape::from_id(str_or(p, "shape", "round")).ok_or_else(|| bad(CMD, "shape: round|ellipse|line|square|diamond|cross"))?; - BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: num(p, "angle", 45.0), shape } + BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: angle as f32, shape } } m => { return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 8ac4e6d332..d6d020699e 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -291,6 +291,38 @@ fn bitmap_methods_need_grayscale() { } } +#[test] +fn bitmap_halftone_rejects_bad_angles_and_frequencies_without_editing_the_document() { + // Photoshop's Halftone Screen takes an angle in -180..180 degrees (#4214): 1e39 used to become + // inf, then NaN in the screen rotation, and an almost white bitmap landed in history. + let mut s = session(16, 16, 8, "grayscale"); + let before = s.active().unwrap().history.past_len(); + let bad = [ + ("angle", json!(1e39)), + ("angle", json!(500)), + ("angle", json!(-181)), + ("angle", json!(180.01)), + ("angle", json!("x")), + ("frequency", json!("x")), + ("frequency", json!(true)), + ]; + for (key, value) in bad { + let mut p = json!({"method": "halftone"}); + p[key] = value.clone(); + assert!(s.execute("image.mode.bitmap", p).is_err(), "{key}: {value}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{key}: {value}"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale, "{key}: {value}"); + } + for angle in [-180.0, 30.0, 180.0] { + s.execute("image.mode.bitmap", json!({"method": "halftone", "angle": angle})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap, "{angle}"); + s.execute("edit.undo", json!({})).unwrap(); + } + // Frequency stays clamped to the documented 1..999 lines/inch. + s.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 1e39})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); From 95663451c06e846d87d122766193ed3329db3dfd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:54 +0200 Subject: [PATCH 075/223] Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223) CMYK samples are ink (1 = full ink), but both filters assumed light polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink (black) instead of white; Solarize inverted channels above 50%, i.e. the shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find Edges writes the edge strength as ink, Solarize inverts channels below 50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/algo/src/lib.rs | 2 +- crates/algo/src/stylize.rs | 18 ++++++++++++++++-- crates/algo/src/tests.rs | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index fdbe6dd72b..cdd73c0091 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -740,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), FilterParams::FindEdges => stylize::find_edges(src, out, ctx), - FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }), + FilterParams::Solarize => stylize::solarize(src, out, ctx), FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v), FilterParams::Desaturate => stylize::desaturate(src, out, ctx), FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle), diff --git a/crates/algo/src/stylize.rs b/crates/algo/src/stylize.rs index 2a88c6ed6e..750923d1a9 100644 --- a/crates/algo/src/stylize.rs +++ b/crates/algo/src/stylize.rs @@ -5,6 +5,7 @@ use photocraft_geom::Rect; use photocraft_raster::{from_rgba, to_rgba}; use crate::Ctx; +use crate::fxutil::subtractive; use crate::image::Image; fn fmt(ctx: &Ctx) -> PixelFormat { @@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) { px.copy_from_slice(&v[..px.len()]); } +/// Solarize: inverts the highlights. CMYK samples are ink, so there the +/// channels below 50% ink (the light ones) invert. +pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + if subtractive(ctx) { + per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v }) + } else { + per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }) + } +} + /// Colour channels through `f` (alpha untouched). pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec { let n = src.ch; @@ -167,10 +178,12 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32, res } -/// Find Edges: Sobel magnitude per colour channel, dark edges on white. +/// Find Edges: Sobel magnitude per colour channel, dark edges on white +/// (CMYK samples are ink, so there the edge strength is the ink itself). pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let n = src.ch; let cc = if ctx.alpha { n - 1 } else { n }; + let sub = subtractive(ctx); let mut res = src.crop(out); let w = out.width() as usize; for (i, px) in res.chunks_exact_mut(n).enumerate() { @@ -179,7 +192,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c); let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1)); let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1)); - *v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + *v = if sub { e } else { 1.0 - e }; } } res diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 763cc0bcee..4ef09470fb 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -367,6 +367,30 @@ fn stylize_basics() { assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6); } +/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink) +/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched. +#[test] +fn find_edges_and_solarize_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + let want = [0.9, 0.8, 0.8, 0.9, 0.6]; + assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}"); + } + // RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert. + let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}"); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}"); +} + #[test] fn distortions_are_identity_at_zero() { let s = pattern(SampleType::U8, R); From ab828712712f638c84de877cd5a89877f6c10fbf Mon Sep 17 00:00:00 2001 From: Alon Date: Tue, 13 Oct 2026 04:17:57 +0300 Subject: [PATCH 076/223] fix(compose): paint interior effects over the layer's blend mode (#3996) (#4231) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/advanced_tests.rs | 20 ++++++++++++++++++++ crates/compose/src/effects.rs | 23 +++++++++++++++++++---- crates/gpu/src/plan.rs | 18 ++++++++++++++++-- crates/gpu/tests/parity.rs | 1 + 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 0ca3bdadb9..9a8723c41d 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -246,6 +246,26 @@ fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() { } } +#[test] +fn interior_effects_blend_over_the_layer_mode_unless_grouped() { + // #3996: #888888 under a black Color-mode layer with a red Overlay colour overlay. The + // layer's mode applies to its own pixels (grey stays grey), then the overlay blends onto + // that result and tints it; as a group the overlay of black is black, so grey remains. + let grey = 136.0 / 255.0; + let lift = 1.0 - 2.0 * (1.0 - grey); + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "grey", full(&d), [grey, grey, grey, 1.0])); + let mut l = solid(&d, "black", full(&d), [0.0, 0.0, 0.0, 1.0]); + l.blend = BlendMode::Color; + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }]; + d.layers.push(l); + check(&d, 2, 2, [1.0, lift, lift, 1.0], "default: overlay over the Color blend"); + d.layers[1].advanced.blend_interior = true; + check(&d, 2, 2, [grey, grey, grey, 1.0], "as group: overlay inside the Color blend"); + } +} + #[test] fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { for depth in DEPTHS { diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 05bd1f10da..33af1a08b6 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -4,8 +4,9 @@ //! drop shadows, outer glows, outer bevel, then the layer itself (content at //! fill opacity) with pattern/gradient/colour overlays, satin, inner glows, //! inner shadows, strokes and the inner bevel on top. Exterior effects blend -//! straight into the backdrop with their own modes; the layer and its -//! interior effects blend with the layer's mode. Emboss and pillow emboss then +//! straight into the backdrop with their own modes; the layer blends with its +//! mode and the interior effects paint over the result, or blend with it as a +//! group (Blend Interior Effects as Group, [`premerges_layer`]). Emboss and pillow emboss then //! shade the composited result (inside and outside halves). Layer opacity //! applies to the whole stack; fill opacity only to the layer's own pixels. //! @@ -1791,6 +1792,14 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { } } +/// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its +/// own pixels only, and the interior effects paint over the blended result. The layer is merged +/// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer +/// tints the grey beneath it (#3996). Normal-like modes give the same result either way. +pub fn premerges_layer(layer: &Layer) -> bool { + !layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve) +} + /// Composites `content` (the layer's own pixels over `big`, alpha already /// masked, clipped layers applied) plus its effects into `backdrop`. pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) { @@ -1889,6 +1898,13 @@ pub(crate) fn composite_with_effects_prepared( } }; let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }; + let gamma = crate::text_gamma(layer); + let premerge = premerges_layer(layer); + if premerge { + for (lp, wp) in lay.px.iter_mut().zip(&work.px) { + *lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma); + } + } let rel = |m: Map| -> Map { let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect(); Map { w: m.w, h: m.h, v } @@ -2051,8 +2067,7 @@ pub(crate) fn composite_with_effects_prepared( } // Layer (with interior effects) onto the exterior result, in the layer's mode. - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; - let gamma = crate::text_gamma(layer); + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; for (wp, lp) in work.px.iter_mut().zip(&lay.px) { if lp[3] > 0.0 { *wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index fb135dc135..52c36f14d9 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -9,7 +9,8 @@ //! effect maps (shadow / glow / satin / bevel coverage, stroke bands) are built once per layer //! state by [`crate::fx`] and sampled here; exterior effects paint into a copy of the backdrop, //! the layer at fill opacity takes the interior effects, and the two merge with the layer's mode -//! and opacity. Effect passes are clipped to the layer's effect region and the result is copied +//! (or the layer merges first and the effects paint over it: `effects::premerges_layer`) and +//! opacity. Effect passes are clipped to the layer's effect region and the result is copied //! back into the backdrop in place, so a small text layer costs only its own pixels. use photocraft_color::BlendMode; @@ -1095,6 +1096,19 @@ impl<'a> Planner<'a> { } else { init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity) }; + // Blend Interior Effects as Group off: the layer merges in its mode first and the + // interior effects paint over the result (`effects::premerges_layer`). + let premerge = photocraft_compose::effects::premerges_layer(layer); + if premerge { + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(self.retain(w)); + p.b = Some(l); + p.mode = layer.blend; + p.flags = gamma_flag(layer); + p.extra[3] = photocraft_compose::text_gamma(layer); + p.clip = Some(clip); + l = self.emit(p); + } for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) @@ -1257,7 +1271,7 @@ impl<'a> Planner<'a> { } } - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev .iter() .filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None }) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 47837a8b65..2bf8bd82b3 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1461,6 +1461,7 @@ fn layer_effects_opacity_fill_blend_and_off_canvas() { (0.6, 0.35, BlendMode::Multiply), (1.0, 0.5, BlendMode::Screen), (0.8, 1.0, BlendMode::Dissolve), + (1.0, 1.0, BlendMode::Color), ] { let mut d = fx_doc(96, 80, SampleType::U8); let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); From 97d06b240b36f88794d09f6c455ac2f7c6c9438d Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Tue, 13 Oct 2026 03:24:09 +0200 Subject: [PATCH 077/223] feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 101 ++++++++++++++++++++++++ crates/ui-egui/src/brush_panel_tests.rs | 90 +++++++++++++++++++++ crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panel_docking.rs | 4 + crates/ui-egui/src/widgets.rs | 19 +++++ 24 files changed, 275 insertions(+) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 4cadb6b212..6ea83d10e9 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -409,6 +409,92 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { commit_gesture(app, ui.ctx(), &before, &b); } +/// Clear brush controls: turns off every section except Smoothing and resets their options +/// to the defaults (Photoshop), keeping locked sections intact. +pub fn clear_brush_controls(current: &BrushSettings) -> BrushSettings { + let mut b = current.clone(); + let d = BrushSettings::default(); + let l = ¤t.locks; + if !l.shape_dynamics { + b.shape_dynamics = d.shape_dynamics; + } + if !l.scattering { + b.scattering = d.scattering; + } + if !l.texture { + b.texture = d.texture; + } + if !l.dual_brush { + b.dual_brush = d.dual_brush; + } + if !l.color_dynamics { + b.color_dynamics = d.color_dynamics; + } + if !l.transfer { + b.transfer = d.transfer; + } + if !l.pose { + b.pose = d.pose; + } + if !l.noise { + b.noise = d.noise; + } + if !l.wet_edges { + b.wet_edges = d.wet_edges; + } + if !l.build_up { + b.build_up = d.build_up; + b.build_up_rate = d.build_up_rate; + } + if !l.protect_texture { + b.protect_texture = d.protect_texture; + } + b +} + +/// Copy the current brush's Texture settings to other painting tools that have Texture enabled. +pub fn copy_texture_to_other_tools(app: &mut PhotocraftApp) { + let texture = app.session.tools.brush.texture.clone(); + for (_, brush) in &mut app.ui.tool_brushes { + if brush.texture.enabled { + brush.texture = texture.clone(); + } + } +} + +/// The Brush Settings panel menu actions (hamburger menu). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if ui.button(tl!("New Brush Preset…")).clicked() { + let name = new_preset_name(&app.session.tools.presets); + run_or_status(app, "brush.presets.save", json!({ "name": name })); + if app.session.tools.presets.iter().any(|p| p.name == name) { + app.ui.brushes_panel.renaming = Some(Renaming { group: false, name: name.clone(), folder: Vec::new(), text: String::new() }); + app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); + } + ui.close(); + } + ui.separator(); + if ui.button(tl!("Clear Brush Controls")).clicked() { + let before = app.session.tools.brush.clone(); + let cleared = clear_brush_controls(&before); + commit(app, &before, &cleared); + ui.close(); + } + let has_locks = app.session.tools.brush.locks != photocraft_paint::brush::SectionLocks::default(); + if ui.add_enabled(has_locks, egui::Button::new(tl!("Reset All Locked Settings"))).clicked() { + let before = app.session.tools.brush.clone(); + let mut after = before.clone(); + after.locks = photocraft_paint::brush::SectionLocks::default(); + commit(app, &before, &after); + ui.close(); + } + let has_texture = app.session.tools.brush.texture.enabled; + if ui.add_enabled(has_texture, egui::Button::new(tl!("Copy Texture to Other Tools"))).clicked() { + copy_texture_to_other_tools(app); + ui.close(); + } +} + pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.brush_settings { return; @@ -442,6 +528,21 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { open = false; } + let menu_btn = widgets::panel_menu_button(ui); + egui::Popup::menu(&menu_btn).show(|ui| { + widgets::style_spectrum_popup_menu(ui); + ui.set_min_width(180.0); + panel_menu(app, ui); + ui.separator(); + if ui.button(tl!("Close")).clicked() { + open = false; + ui.close(); + } + if ui.button(tl!("Close Tab Group")).clicked() { + open = false; + ui.close(); + } + }); }); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 6a2338295c..2e48b2abb9 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1176,6 +1176,96 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(!h.state().ui.views[0].fit_pending); } +#[test] +fn brush_settings_hamburger_menu_actions() { + use egui_kittest::kittest::Queryable; + + // 1. Menu opens and offers Photoshop actions + let mut h = harness(0, 0); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + + assert!(h.query_all_by_label("New Brush Preset…").count() > 0); + assert!(h.query_all_by_label("Clear Brush Controls").count() > 0); + assert!(h.query_all_by_label("Reset All Locked Settings").count() > 0); + assert!(h.query_all_by_label("Copy Texture to Other Tools").count() > 0); + assert!(h.query_all_by_label("Close").count() > 0); + assert!(h.query_all_by_label("Close Tab Group").count() > 0); + + // 2. New Brush Preset… saves a preset + let count0 = h.state().session.tools.presets.len(); + h.get_by_label("New Brush Preset…").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.presets.len(), count0 + 1); + assert_eq!(last_journal(h.state()).unwrap().0, "brush.presets.save"); + + // 3. Clear Brush Controls resets dynamics, keeps locked sections and smoothing + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.size = 45.0; + h.state_mut().session.tools.brush.scattering.enabled = true; + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.locks.texture = true; + h.state_mut().session.tools.brush.smoothing.amount = 0.5; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Clear Brush Controls").click(); + h.run_steps(2); + + let b = &h.state().session.tools.brush; + assert!(!b.scattering.enabled, "unlocked scattering cleared"); + assert!(b.texture.enabled, "locked texture preserved"); + assert_eq!(b.smoothing.amount, 0.5, "smoothing preserved"); + assert_eq!(b.size, 45.0, "tip size preserved"); + + // 4. Reset All Locked Settings clears all locks + assert!(b.locks.texture); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Reset All Locked Settings").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.locks, photocraft_paint::brush::SectionLocks::default()); + + // 5. Copy Texture to Other Tools copies texture to other tools with texture enabled + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.texture.scale = 2.5; + let mut pencil_brush = BrushSettings::default(); + pencil_brush.texture.enabled = true; + pencil_brush.texture.scale = 1.0; + h.state_mut().ui.tool_brushes = vec![(crate::state::Tool::Pencil, pencil_brush)]; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Copy Texture to Other Tools").click(); + h.run_steps(2); + + let pencil = h.state().ui.tool_brushes.iter().find(|(t, _)| *t == crate::state::Tool::Pencil).unwrap(); + assert_eq!(pencil.1.texture.scale, 2.5); + + // 6. Close closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + let close_buttons: Vec<_> = h.query_all_by_label("Close").collect(); + assert!(close_buttons.len() >= 2); + close_buttons.last().unwrap().click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); + + // 7. Close Tab Group closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Close Tab Group").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); +} + #[test] fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 4bd043da66..0aab2769ea 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Brush Settings") { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } if tabs.get(sel) == Some(&"Actions") { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 11438131d7..16c1d9d2d8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3666,6 +3666,9 @@ 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ší) + 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ů # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a9b676634b..25d19ff714 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2698,6 +2698,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5c33da43ca..c840fbcb2c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3756,6 +3756,9 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου + Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων + Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e7f2385d5a..71f4d655e1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2739,6 +2739,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6076c41b5a..7d9c500698 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3059,6 +3059,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a7dd7723b7..56e203fde0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,6 +3972,9 @@ cameraRaw Yellow Sárga 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 + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bb35e8cba7..618687ff4b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3046,6 +3046,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ca8e1cf717..5aa105c5fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4022,6 +4022,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7d0bf7dc47..eebc97e3ef 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3668,6 +3668,9 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Clear Brush Controls ブラシのコントロールを消去 + Reset All Locked Settings ロックされた設定をすべて初期化 + Copy Texture to Other Tools テクスチャを他のツールにコピー # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 135a872867..0e31d48937 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3228,6 +3228,9 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Clear Brush Controls 브러시 제어 지우기 + Reset All Locked Settings 모든 잠긴 설정 재설정 + Copy Texture to Other Tools 다른 도구로 텍스처 복사 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6d8b52bf95..270ec89350 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3818,6 +3818,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7c89aa596d..39c1928ca4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3655,6 +3655,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index be6c0381f0..17247be9bf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3848,6 +3848,9 @@ 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carregar ações… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 125155cadf..0cd3f56b1c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3003,6 +3003,9 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Clear Brush Controls Очистить параметры кисти + Reset All Locked Settings Сбросить все заблокированные параметры + Copy Texture to Other Tools Скопировать текстуру в другие инструменты # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e4d4ec9aed..1f69babad5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,6 +3784,9 @@ 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 + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 83f1250125..253ca6b82f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3756,6 +3756,9 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Clear Brush Controls Очистити параметри пензля + Reset All Locked Settings Скинути всі заблоковані параметри + Copy Texture to Other Tools Скопіювати текстуру в інші інструменти # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 718278b5d2..1d0e15b6e2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3539,6 +3539,9 @@ curves Smooth Làm mượt 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) + 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 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Nạp hành động… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2d5a3d6bf..7b1862545c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2663,6 +2663,9 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Clear Brush Controls 清除画笔控件 + Reset All Locked Settings 复位所有锁定的设置 + Copy Texture to Other Tools 将纹理复制到其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8543251b0c..abcc5cb71b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2648,6 +2648,9 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Clear Brush Controls 清除筆刷控制項 + Reset All Locked Settings 重設所有鎖定的設定 + Copy Texture to Other Tools 複製紋理至其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 4aff73c01e..7a1cb4df12 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -843,6 +843,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "brushSettings" | "brush_settings" => { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } "actions" => { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8d57c51e75..1bb5ee601c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -220,6 +220,25 @@ fn pro_panel( CardResponse::new(strip_resp, mresp, tabs_out) } +/// The panel menu button (hamburger in Pro, ellipsis in Studio), matching the dock and cards. +pub fn panel_menu_button(ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let size = vec2(20.0, 20.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let tint = crate::icons::button_chrome(ui, rect, false, resp.hovered()); + if t.pro { + for k in 0..3 { + let y = rect.center().y - 3.5 + k as f32 * 3.5; + ui.painter().line_segment([pos2(rect.center().x - 5.0, y), pos2(rect.center().x + 5.0, y)], Stroke::new(1.0, tint)); + } + } else { + crate::icons::paint(ui, rect, "ellipsis", 14.0, tint); + } + let tip = tl!("Panel menu"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.on_hover_text(tip) +} + pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); From 37ee4f5a204c2b9e7cbdfba37da79cb6af7425b4 Mon Sep 17 00:00:00 2001 From: Persi <69083413+persi-man@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:45 +0200 Subject: [PATCH 078/223] Simplify the raster stale-block filter for clippy's nonminimal_bool (#4314) Clippy 1.96 flags the `retain` predicate that strips stale content blocks from pixel layers on PSD export (`clippy::nonminimal_bool`), which breaks `cargo clippy -- -D warnings` on current stable. Rewrite it as a single negated disjunction. The four terms, their order and the short-circuiting are unchanged, so the same blocks are kept and dropped. --- crates/io/src/psd_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 38db080873..a17e783366 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -608,7 +608,7 @@ impl Ex { // pixel layers, so it stays. A vector mask (`vmsk`/`vsms`, `vogk`) stays. LayerContent::Raster(_) => { let masked = l.vector_mask.is_some(); - raw.retain(|(k, _)| !RASTER_STALE_KEYS.contains(&k) && !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && !(k == b"vstk" && masked)); + raw.retain(|(k, _)| !(RASTER_STALE_KEYS.contains(&k) || adjust_map::ADJUSTMENT_KEYS.contains(&k) || k == b"CgEd" || (k == b"vstk" && masked))); } LayerContent::Group(_) => {} } From 1412ac26e2d0257543caed8765a1522a4e94e768 Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Tue, 13 Oct 2026 09:26:47 +0800 Subject: [PATCH 079/223] Save for Web and Export As remember their settings across restarts (FILE-215-18) (#4184) * engine: remember the last Save for Web settings in the preferences * engine: test that a complete export remembers its settings for the next launch * ui: seed the Save for Web dialog from the remembered preferences * ui: Export As opens with the settings it was used with last * scorecard: FILE-215-18 done, the export dialogs remember their settings * docs: scorecard, File compatibility 7 done of 27 * Regenerate the scorecard Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 17 ++++++ crates/engine/src/web_cmds/tests.rs | 17 ++++++ crates/ui-egui/src/export_dialog.rs | 91 +++++++++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 26 ++++++++- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 6 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index cccddbf9f3..727e355902 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -837,6 +837,21 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu save_for_web_impl(s, p, write, false) } +/// Settings to seed the Save for Web dialog from on the next launch: `file_menu` is session +/// state, so a complete export also writes the preferences' remembered dialog settings (like +/// Fill, Liquify and the filter dialogs). The destination is per export, never remembered. +fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { + let mut m = settings.clone(); + if let Some(o) = m.as_object_mut() { + for k in ["path", "dir", "trustedSliceHtml"] { + o.remove(k); + } + } + s.prefs.edit(|p| { + p.dialogs.insert(cmd.to_string(), m); + }); +} + fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { @@ -883,6 +898,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); @@ -973,6 +989,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u write(&web_derived_path(&dir, &format!("{base}.html"), native_paths)?, page.as_bytes())?; html_path = Some(hp); } + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 787560f425..9550cd849c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -47,6 +47,23 @@ fn estimates_preserve_last_successful_export_settings() { assert_eq!(s.file_menu.last_web.as_ref(), Some(&saved), "preview estimates must not replace the settings from the last successful export"); } +/// The next launch's dialog seeds from the preferences: `file_menu` is not saved, so a complete +/// export remembers its settings in `prefs.dialogs` too, as Fill and the filter dialogs do. +#[test] +fn a_complete_export_remembers_its_settings_in_the_preferences() { + let dir = tmp("remember_settings"); + let mut s = session(8); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "quality": 42, "path": format!("{dir}/out.jpg")})).unwrap(); + let saved = s.prefs().dialogs.get("file.export.saveForWebLegacy").cloned().expect("remembered"); + assert_eq!(saved["format"], "jpeg"); + assert_eq!(saved["quality"], 42); + assert!(saved.get("path").is_none(), "the destination is per export, not remembered"); + + // Preview estimates remember nothing, like they don't replace `file_menu`. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "width": 16, "height": 12})).unwrap(); + assert_eq!(s.prefs().dialogs.get("file.export.saveForWebLegacy").unwrap()["format"], "jpeg"); +} + #[test] fn platform_writer_receives_resized_image_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index cd520d5c30..79abeaadbf 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -13,6 +13,46 @@ use crate::{ExportSettings, PhotocraftApp}; const FORMATS: [(&str, &str); 5] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA")]; +/// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with +/// last under its command id and seeds the next dialog from them, as Fill, Liquify and the +/// filter dialogs do. +pub(crate) const COMMAND: &str = "file.export.exportAs"; + +/// The dialog is seeded from the settings it was used with last (see [`COMMAND`]); a value that +/// no longer matches a control (a hand-edited preferences file) is left at its default. +fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { + let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) else { return }; + if let Some(v) = saved.get("format").and_then(Value::as_str).filter(|v| FORMATS.iter().any(|(k, _)| k == v)) { + f.insert("format".into(), json!(v)); + } + if let Some(v) = saved.get("quality").and_then(Value::as_f64).filter(|v| (1.0..=100.0).contains(v)) { + f.insert("quality".into(), json!(v)); + } + if let Some(v) = saved.get("scale").and_then(Value::as_f64).filter(|v| (1.0..=1000.0).contains(v)) { + f.insert("scale".into(), json!(v)); + } + for key in ["lossless", "transparency"] { + if let Some(v) = saved.get(key).and_then(Value::as_bool) { + f.insert(key.into(), json!(v)); + } + } + if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "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)); + } +} + +/// 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"]; + 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)); + }); +} + pub fn open(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; let mut f = Map::new(); @@ -27,6 +67,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("tgaBits".into(), json!("auto")); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); + seed_remembered(app, &mut f); Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -355,6 +396,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { + remember(app, f); if let Some(layers) = f.get("__layers").and_then(Value::as_array) { let ids: Vec<_> = layers.iter().filter_map(Value::as_u64).map(photocraft_doc::LayerId).collect(); return confirm_layers(app, f, &ids); @@ -452,6 +494,55 @@ fn quick_export(app: &mut PhotocraftApp, p: Value) -> Result { mod tests { use super::*; + /// Export As reopens with the settings it was used with last (Preferences ▸ remembered + /// dialog settings), and a value no control accepts keeps its default. + #[test] + fn export_as_opens_with_the_settings_used_last() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + COMMAND.into(), + json!({"format": "jpg", "quality": 88, "scale": 37, "transparency": false, "metadata": "all", "tgaBits": "24", "lossless": true}), + ); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "jpg"); + assert_eq!(f["quality"], json!(88.0)); + assert_eq!(f["scale"], json!(37.0)); + assert_eq!(f["transparency"], false); + assert_eq!(f["metadata"], "all"); + assert_eq!(f["tgaBits"], "24"); + + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), json!({"format": "nope", "scale": 0, "metadata": "sometimes"})); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png"); + assert_eq!(f["scale"], 100); + assert_eq!(f["metadata"], "none"); + } + + /// Only the dialog's own settings are remembered: not the destination, the document size or + /// the layer the dialog was opened with. + #[test] + fn remembering_keeps_only_the_dialog_settings() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut f = Map::new(); + f.insert("format".into(), json!("webp")); + f.insert("scale".into(), json!(50)); + f.insert("path".into(), json!("/tmp/x.webp")); + f.insert("__w".into(), json!(1200)); + f.insert("__layer".into(), json!(3)); + remember(&mut app, &f); + let saved = app.session.prefs().dialogs.get(COMMAND).cloned().unwrap(); + assert_eq!(saved["format"], "webp"); + assert_eq!(saved["scale"], 50); + assert!(saved.get("path").is_none() && saved.get("__w").is_none() && saved.get("__layer").is_none()); + } + #[test] fn export_document_scales_height_when_rounded_width_is_unchanged() { use photocraft_doc::{Color, ColorMode, SampleType, Size}; diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index e3884796f8..d7e2252e71 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -192,8 +192,16 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { f.insert("__command".into(), json!(WEB_COMMAND)); f.insert("__label".into(), json!("Save for Web (Legacy)")); f.insert("__view".into(), json!("2up")); - // Remember encoding choices, but each document starts at its own full size. - let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); + // Remember encoding choices, but each document starts at its own full size. This session's + // last export lives in `file_menu`; the last one before a restart comes from the + // preferences' remembered dialog settings (`web_cmds::remember_web_settings`). + let last = app + .session + .file_menu + .last_web + .clone() + .or_else(|| app.session.prefs().dialogs.get(WEB_COMMAND).cloned()) + .unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { @@ -937,6 +945,20 @@ mod tests { assert!(!d.fields.contains_key("path")); } + /// A fresh session's dialog seeds from the preferences: the last export before a restart is + /// remembered there (`web_cmds::remember_web_settings`), as `file_menu` is not saved. + #[test] + fn the_save_for_web_dialog_seeds_from_the_remembered_preferences() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert(WEB_COMMAND.into(), json!({"format": "png8", "colors": 64})); + }); + let id = open_web(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png8"); + assert_eq!(f["colors"], 64); + } + #[test] fn save_for_web_starts_at_the_current_document_size() { let (mut app, _) = app(); diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e..651d68309f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -115,7 +115,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | -| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | +| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3c93a9d5b3..780bee4f16 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -129,7 +129,7 @@ id = "FILE-215-18" target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling" status = "partial" issue = 215 -note = "file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults" +note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling" [[item]] id = "FILE-215-19" From ab5a6c5ed20b3200dea832c81a46bf74386458e9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:26:49 +0700 Subject: [PATCH 080/223] fix(layer-picker): include text bounds in right-click layer list (#4288) --- crates/engine/src/pick_cmds.rs | 14 +++++++++----- crates/ui-egui/src/layer_pick_ui.rs | 6 +++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index d3bee1c6ac..c40e15e9b8 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -92,9 +92,9 @@ fn pick(s: &mut Session, p: &Value) -> Result { let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32; let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - // The right-click list names the layers with pixels under the pointer. + // The right-click list includes type layers between glyphs, like Auto-Select. if p.get("list").and_then(Value::as_bool).unwrap_or(false) { - let hits = layers_hit(&doc, x, y, false); + let hits = layers_hit(&doc, x, y, true); let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } @@ -190,7 +190,7 @@ pub fn specs() -> Vec { label: "Auto-Select Layer", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return every layer with pixels there, topmost first)} → {layer} | {layers}"##, + params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##, enabled: has_doc, journal: false, run: pick, @@ -372,9 +372,10 @@ mod tests { assert_eq!(pick(&mut s, 12, 15), t.0, "on a glyph"); assert_eq!(pick(&mut s, 17, 15), t.0, "between the glyphs: the type layer, not the one under it"); assert_eq!(pick(&mut s, 17, 20), bg.0, "below the text bounds"); - // The right-click list still names only the layers with pixels under the pointer. + // The context menu also reaches this type layer between the glyphs (#4259). let list = s.execute("layer.pickAt", json!({"x": 17, "y": 15, "list": true})).unwrap(); - assert_eq!(list["layers"].as_array().unwrap().len(), 1); + assert_eq!(list["layers"][0]["layer"], t.0); + assert_eq!(list["layers"][1]["layer"], bg.0); } #[test] @@ -398,6 +399,9 @@ mod tests { let Some(x) = gap else { return }; assert_eq!(layers_at(&doc, x, y), vec![bg], "no type pixels at the gap"); assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); + let menu = s.execute("layer.pickAt", json!({"x": x, "y": y, "list": true})).unwrap(); + assert_eq!(menu["layers"][0]["layer"], t, "type layer stays above its background in the menu"); + assert_eq!(menu["layers"][1]["layer"], bg.0); } #[test] diff --git a/crates/ui-egui/src/layer_pick_ui.rs b/crates/ui-egui/src/layer_pick_ui.rs index 654751b953..b75fe2b47b 100644 --- a/crates/ui-egui/src/layer_pick_ui.rs +++ b/crates/ui-egui/src/layer_pick_ui.rs @@ -2,8 +2,8 @@ //! with any tool, lists the layers with visible pixels under the pointer, topmost first, as //! Photoshop does. Choosing one selects it. //! -//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, layer masks, hidden -//! layers and hidden groups skipped), and choosing runs `layer.select`, so the menu is the same +//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, text bounds, +//! layer masks, hidden layers and hidden groups), and choosing runs `layer.select`, so the menu is the same //! two commands an agent would run. Painting tools keep their plain right-click (the Brush //! Preset picker, #268); the ⌘/Ctrl variant reaches the layer list from them too. @@ -27,7 +27,7 @@ pub fn is_gesture(tool: Tool, mods: Modifiers) -> bool { tool == Tool::Move || mods.command } -/// The layers with pixels at document point (x, y), topmost first. +/// The layers at document point (x, y), topmost first, including text between glyphs. pub fn layers_at(app: &mut PhotocraftApp, x: f64, y: f64) -> Vec<(u64, String)> { if !(x.is_finite() && y.is_finite()) { return Vec::new(); From b79d0a0bcf61eac9b4b9c935777baa32b4ff1fe6 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:53 +0200 Subject: [PATCH 081/223] feat(gradients): Photoshop's interpolation method for Gradient Overlay, gradient Stroke and the Gradient tool (#4292) layer.layerStyle.gradientOverlay, layer.layerStyle.stroke (gradient) and paint.gradient take "method" (classic|perceptual|linear|smooth) and "smoothness" (0..100, default 100). The stops are baked with the same model PSD import uses (io::gradient_bake, now public as bake_gradient and GradientMethod), so the compositors' linear interpolation reproduces Photoshop's curve. Without a method the stops stay as they were. Measured in Photoshop 27.11 (max level error vs main -> this change): Gradient Overlay linear/reflected styles, any angle/scale/reverse 12 -> 0-1, radial/diamond 16 -> 4-5; gradient Stroke 12 -> 2-4; Gradient tool black -> white Classic 13-14 -> 2-3; red -> blue Classic 12 -> 1, Perceptual 83 -> 2, Linear 64 -> 4, Smooth 83 -> 3. A 3-stop Classic gradient improves only 14 -> 10 (the multi-stop smoothness spline is a separate follow-up). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 45 +++++++++++++++++++++++++++++--- crates/engine/src/paint_cmds.rs | 19 +++++++++++++- crates/io/src/gradient_bake.rs | 15 +++++++++-- crates/io/src/lib.rs | 1 + 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index b7fc8b7b01..fbbc949e20 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -52,9 +52,21 @@ fn color(p: &Value, k: &str, d: [f32; 3]) -> Color { let c = color_of(p, k).map_or(d, |c| c.to_rgb()); Color::rgb(c[0], c[1], c[2]) } +/// The stops of a two-colour gradient, baked with Photoshop's interpolation when `method` names +/// one (`classic`, `perceptual`, `linear`, `smooth`; `smoothness` 0..=100, default 100 as in +/// Photoshop): the compositors interpolate stops linearly, so the curve becomes dense stops, as on +/// PSD import. Photoshop 27.11's Classic Gradient Overlay eases (t + smoothstep(t)) / 2 between the +/// colours: a straight ramp was up to 12 levels off. +fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> { + let stops = vec![(0.0, from), (1.0, to)]; + match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) => photocraft_io::bake_gradient(stops, &[0.5], (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m), + None => stops, + } +} fn gradient(p: &Value) -> Gradient { Gradient { - stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, color(p, "to", [1.0; 3]))], + stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), angle: f(p, "angle", 90.0), scale: f(p, "scale", 100.0) / 100.0, @@ -546,6 +558,13 @@ fn fresh_effect(kind: &str) -> Option { /// Edits an existing gradient in place: only the end colours and geometry. /// Intermediate stops, opacity stops, alignment and offset survive a round trip. fn overlay_gradient(g: &mut Gradient, p: &Value) { + // A method rebuilds the gradient from its end colours (the stops between are the old curve). + if p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) { + let ends = (g.stops.first().map(|s| s.1), g.stops.last().map(|s| s.1)); + let from = color_of(p, "from").or(ends.0).unwrap_or(Color::BLACK); + let to = color_of(p, "to").or(ends.1).unwrap_or(Color::WHITE); + g.stops = gradient_stops(p, from, to); + } if let Some(c) = color_of(p, "from") && let Some(first) = g.stops.first_mut() { @@ -983,13 +1002,13 @@ pub fn specs() -> Vec { style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", @@ -1342,6 +1361,26 @@ mod tests { assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero"); } + #[test] + fn gradient_overlay_method_matches_photoshop() { + // Photoshop 27.11, Classic Gradient Overlay #000000 → #ffffff (Smoothness 100 %): 0.037 at + // t = 1/16 and 0.5 in the middle; straight stops would give 0.0625. + let mut s = session(); + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "method": "classic"})).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + let at = |t: f32| { + let w = gradient.stops.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-6); + w[0].1.to_rgb()[0] + (w[1].1.to_rgb()[0] - w[0].1.to_rgb()[0]) * u + }; + assert!((at(1.0 / 16.0) - 0.037).abs() < 0.004, "{}", at(1.0 / 16.0)); + assert!((at(0.5) - 0.5).abs() < 0.004, "{}", at(0.5)); + // Without a method the two stops stay as they are. + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "add": true})).unwrap(); + let plain = effects(&s).iter().filter(|e| matches!(e, Effect::GradientOverlay { gradient, .. } if gradient.stops.len() == 2)).count(); + assert_eq!(plain, 1, "the added overlay keeps its two stops"); + } + #[test] fn contour_presets_and_custom_curves_apply_in_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 4dc39f1517..497d7f4fb1 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -123,6 +123,23 @@ fn gradient(s: &mut Session, p: &Value) -> Result { gradients::apply_opacity(cs, &gradients::transparency_param(p, "paint.gradient")?.unwrap_or_default()) } }; + // Photoshop's interpolation (`method`, with `smoothness` 0..=100, default 100) baked into dense + // stops, as for Gradient Overlay: Photoshop 27.11's Classic black → white is 0.037 at t = 1/16 + // where straight stops give 0.0625. + let stops = match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) if stops.len() >= 2 => { + let colors = stops.iter().map(|(t, c)| (*t, photocraft_color::Color::rgba(c[0], c[1], c[2], c[3]))).collect(); + let mids = vec![0.5; stops.len() - 1]; + photocraft_io::bake_gradient(colors, &mids, (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m) + .into_iter() + .map(|(t, c)| { + let rgb = c.to_rgb(); + (t, [rgb[0], rgb[1], rgb[2], c.alpha]) + }) + .collect() + } + _ => stops, + }; let reverse = b(p, "reverse", false); let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); @@ -155,7 +172,7 @@ pub fn specs() -> Vec { label: "Gradient", menu: &[], shortcut: None, - params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: gradient, journal: true, diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index c9265a9102..e452ae9e1f 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -14,8 +14,9 @@ use photocraft_color::{Color, ColorMode}; +/// Photoshop's gradient interpolation methods. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Method { +pub enum Method { Classic, Perceptual, Linear, @@ -23,6 +24,16 @@ pub(crate) enum Method { } impl Method { + /// The method a command names (`classic`, `perceptual`, `linear`, `smooth`); `None` otherwise. + pub fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "classic" => Some(Method::Classic), + "perceptual" => Some(Method::Perceptual), + "linear" => Some(Method::Linear), + "smooth" => Some(Method::Smooth), + _ => None, + } + } pub(crate) fn from_code(code: Option<&[u8]>) -> Method { match code { Some(b"Perc") => Method::Perceptual, @@ -140,7 +151,7 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32, m: Method) /// Dense linear stops reproducing Photoshop's interpolation. Returns the input unchanged when it /// already interpolates linearly in sRGB. -pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { +pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); // Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the // compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab). diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0a1577714d..9852a48516 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -42,6 +42,7 @@ pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; mod multichannel_map; From 96f4256ced260f0466f09f8d11eb84d3209db8d0 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:59 +0200 Subject: [PATCH 082/223] fix(type): the Type tool's canvas pick skips text in hidden groups (#4320) (#4332) --- crates/doc/src/lib.rs | 5 ++ crates/engine/src/type_caret_cmds.rs | 4 +- crates/ui-egui/src/type_tool.rs | 85 +++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e2a46e4079..a5c5381ee1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1047,6 +1047,11 @@ impl Document { (1..=path.len()).filter_map(|n| self.layer_at(path.get(..n)?)).fold(Locks::default(), |a, l| a.union(l.locks)) } + /// Whether the layer at `path` is shown: it and every group around it are visible. + pub fn shown_at(&self, path: &[usize]) -> bool { + (1..=path.len()).all(|n| path.get(..n).and_then(|p| self.layer_at(p)).is_some_and(|l| l.visible)) + } + /// [`Self::locks_at`] for the layer `id`. pub fn effective_locks(&self, id: LayerId) -> Locks { self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p)) diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index 528472cc25..fbc9322c62 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -106,9 +106,7 @@ fn topmost(doc: &Document, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(path, _, layer)| { - matches!(layer.content, LayerContent::Text(_)) && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|ancestor| ancestor.visible)) - }) + .filter(|(path, _, layer)| matches!(layer.content, LayerContent::Text(_)) && doc.shown_at(path)) .map(|(_, _, layer)| layer.id) .collect(); ids.reverse(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 6a801b3385..291fdde78a 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -52,7 +52,8 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { (p.x as f32, p.y as f32) } -/// Topmost visible type layer whose laid-out text contains the document point. +/// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text +/// contains the document point. fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); @@ -61,7 +62,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(_, _, l)| (l.visible || editing == Some(l.id)) && matches!(l.content, LayerContent::Text(_))) + .filter(|(path, _, l)| matches!(l.content, LayerContent::Text(_)) && (editing == Some(l.id) || doc.shown_at(path))) .map(|(_, _, l)| l.id) .collect(); ids.reverse(); // walk() is bottom-up; hit the topmost first @@ -2227,6 +2228,86 @@ mod tests { current_text(app, LayerId(ed.layer)).unwrap() } + /// A committed type layer reading `s` at the same spot as the others (stacked variants, #4320). + fn stacked_variant(app: &mut PhotocraftApp, s: &str) -> LayerId { + app.ui.text_edit = None; + pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + insert(app, s); + commit(app); + app.session.active().unwrap().active_layer.unwrap() + } + + fn hide(app: &mut PhotocraftApp, id: LayerId) { + app.run("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap(); + } + + /// Wraps `id` in a new group and returns the group. + fn group_of(app: &mut PhotocraftApp, id: LayerId) -> LayerId { + app.run("layer.select", json!({"layer": id.0, "mode": "replace"})).unwrap(); + app.run("layer.groupLayers", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let path = doc.path_of(id).unwrap(); + doc.layer_at(&path[..path.len() - 1]).unwrap().id + } + + /// Three type variants at one spot, bottom to top: A, B, C. + fn variants() -> (PhotocraftApp, [LayerId; 3]) { + let mut app = app(); + let ids = ["Variant A", "Variant B", "Variant C"].map(|s| stacked_variant(&mut app, s)); + (app, ids) + } + + const ON_TEXT: (f64, f64) = (60.0, 95.0); + + /// #4320: variants hidden through their groups above the shown one are not picked. + #[test] + fn type_click_skips_text_in_hidden_groups_above_the_shown_variant() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + assert!(pointer_down(&mut app, ON_TEXT.0, ON_TEXT.1, egui::Modifiers::NONE)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0), "the click edits the shown variant"); + } + + #[test] + fn type_click_skips_text_in_a_nested_hidden_group() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + let inner = group_of(&mut app, c); + let outer = group_of(&mut app, inner); + hide(&mut app, outer); + assert!(app.session.active().unwrap().doc.layer(c).unwrap().visible, "only the outer group is hidden"); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + } + + #[test] + fn type_click_skips_own_hidden_variants_above_and_below() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + hide(&mut app, c); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a), "hidden variants above"); + let (mut app, [a, b, c]) = variants(); + hide(&mut app, a); + hide(&mut app, b); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(c), "hidden variants below"); + } + + /// The Move tool's double-click on type (#1132) shares the pick. + #[test] + fn move_double_click_edits_the_shown_variant_not_one_in_a_hidden_group() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0)); + } + /// Photoshop: a click on existing type edits it; a ⇧-click there starts a new type layer. #[test] fn shift_click_on_existing_type_starts_new_text() { From f6db7a8115269f756398ec1d05965828e7559512 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:27:03 +0200 Subject: [PATCH 083/223] Smart Objects Warp: mesh knots span the contents, not the skewed bounding frame (#4113) (#4341) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/smart_cmds.rs | 14 ++++ crates/engine/src/smart_cmds/tests.rs | 24 +++++++ crates/engine/src/warp_cmds.rs | 94 +++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e632251982..635c5bb088 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -387,6 +387,20 @@ fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result }) } +/// The box of a smart object's source in source pixels (what its warp's `us`/`vs` span), or `None` +/// when the source can't be read. +pub(crate) fn source_box(doc: &Document, sm: &SmartObject) -> Option<[f64; 4]> { + let (name, bytes) = source_bytes(&doc.metadata, &sm.source)?; + let fmt = doc.pixel_format(); + let img = match sm.stack_mode { + Some(mode) => stack_image(&name, &bytes, fmt, mode), + None => source_image(&name, &bytes, fmt), + } + .ok()?; + let r = img.bounds; + Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]) +} + /// Renders a smart object from its source. `Ok(None)` when the source is unavailable (a missing /// linked file, or a PSD placed layer without embedded data): callers keep the existing cache. pub fn render(doc: &Document, sm: &SmartObject) -> Result> { diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 90bb325a2c..aa88fe3fed 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -622,6 +622,30 @@ fn warp_on_a_distorted_smart_object_follows_the_corners() { assert_eq!(after.perspective, before); } +/// A `mesh` whose points are exactly the placement leaves a sheared smart object unchanged: its +/// knots span the contents, not the bounding frame of the warp box seen from the contents (#4113). +#[test] +fn identity_mesh_on_a_skewed_smart_object_changes_nothing() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let r = active_smart(&s).cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let quad = [[x0, y0], [x1, y0 + 10.0], [x1, y1 + 10.0], [x0, y1]]; + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap(); + let before = flat(&s); + let sm = active_smart(&s); + let (h, b) = (placement(&sm), crate::smart_cmds::source_box(&s.active().unwrap().doc, &sm).unwrap()); + let place = |u: f64, v: f64| { + let (x, y) = h.apply(b[0] + u * (b[2] - b[0]), b[1] + v * (b[3] - b[1])); + [x, y] + }; + let mesh = photocraft_geom::warp::BezierMesh::fit(&place, vec![0.0, 1.0], vec![0.0, 0.25, 1.0]); + let rect = [x0, y0, x1, y1 + 10.0]; + s.execute("layer.smartObjects.warp", json!({"layer": id, "style": "custom", "rect": rect, "mesh": mesh})).unwrap(); + assert!(mean_diff(&before, &flat(&s)) < 0.01, "mean diff {}", mean_diff(&before, &flat(&s))); +} + fn convert_to_layers(s: &mut Session) -> Value { s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap() } diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index e64c93279d..5142b0a940 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -112,7 +112,7 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). -fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> { +fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); @@ -124,7 +124,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - warp_layer(None, locks, c, w, rect, interp)?; + warp_layer(None, locks, c, w, rect, interp, frames)?; } } LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())), @@ -138,7 +138,14 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, let sb = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); let src_warp = match w.style { WarpStyle::None => None, - WarpStyle::Custom => Some(Warp::custom(w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)), sb)), + WarpStyle::Custom => { + let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)); + // A `mesh` parameter's knots span the contents, not the warp box's image (#4113). + match frames.and_then(|f| f.get(&l.id)).and_then(|&c| content_frame_warp(&mesh, c, sb)) { + Some(cw) => Some(cw), + None => Some(Warp::custom(mesh, sb)), + } + } _ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }), }; sm.warp = src_warp.filter(|w| !w.is_identity()); @@ -191,6 +198,75 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, Ok(()) } +/// Source boxes of the smart objects under a warped layer, by layer id. +type Frames = std::collections::HashMap; + +fn source_frames(doc: &Document, l: &Layer, out: &mut Frames) { + match &l.content { + LayerContent::Group(g) => g.children.iter().for_each(|c| source_frames(doc, c, out)), + LayerContent::Smart(sm) => { + if let Some(b) = crate::smart_cmds::source_box(doc, sm) { + out.insert(l.id, b); + } + } + _ => {} + } +} + +/// Rescales `knots` (spanning the box starting at `c0`, `cs` long) to span the frame `t0`, `ts` +/// (which contains it). Returns the knots and whether the frame extends past the box at the start / end. +fn reframe_knots(knots: &[f64], c0: f64, cs: f64, t0: f64, ts: f64) -> (Vec, bool, bool) { + let pre = (c0 - t0) / ts > 1e-9; + let post = (t0 + ts - (c0 + cs)) / ts > 1e-9; + let mut out: Vec = Vec::with_capacity(knots.len() + 2); + if pre { + out.push(0.0); + } + out.extend(knots.iter().map(|k| (c0 + k * cs - t0) / ts)); + if post { + out.push(1.0); + } + // Keep the ends exactly 0 and 1. + if let Some(f) = out.first_mut() { + *f = 0.0; + } + if let Some(l) = out.last_mut() { + *l = 1.0; + } + (out, pre, post) +} + +/// `items` with three more copies of the first / last one at the front (`pre`) / back (`post`). +fn pad(items: &[T], pre: bool, post: bool) -> Option> { + let mut out = Vec::with_capacity(items.len() + 6); + if pre { + out.extend(std::iter::repeat_n(items.first()?.clone(), 3)); + } + out.extend_from_slice(items); + if post { + out.extend(std::iter::repeat_n(items.last()?.clone(), 3)); + } + Some(out) +} + +/// A stored smart-object warp from `mesh` (points in source space) whose knots span the source +/// box `c`: the knots are re-expressed over the frame `hull(c, b)` (`b` = the warp box's image in +/// source space), with a degenerate patch padding every side where the frame extends past `c`. +/// The surface over `c` is exactly the mesh's (#4113). +fn content_frame_warp(mesh: &BezierMesh, c: [f64; 4], b: [f64; 4]) -> Option { + let t = [c[0].min(b[0]), c[1].min(b[1]), c[2].max(b[2]), c[3].max(b[3])]; + let (cw, ch) = (c[2] - c[0], c[3] - c[1]); + if !(cw > 0.0 && ch > 0.0 && t.iter().all(|v| v.is_finite())) { + return None; + } + let (us, left, right) = reframe_knots(&mesh.us, c[0], cw, t[0], t[2] - t[0]); + let (vs, top, bottom) = reframe_knots(&mesh.vs, c[1], ch, t[1], t[3] - t[1]); + let rows: Vec> = mesh.points.chunks(mesh.nx()).map(|r| pad(r, left, right)).collect::>()?; + let rows = pad(&rows, top, bottom)?; + let m = BezierMesh { us, vs, points: rows.into_iter().flatten().collect() }; + m.is_valid().then(|| Warp::custom(m, t)) +} + /// Largest warp frame side: far above any real layer, far below what would exhaust memory. const MAX_WARP_SIDE: u32 = 100_000; @@ -270,13 +346,21 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(json!({"layer": id.0, "changed": false})); } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); + let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); s.edit("Warp", |doc, _| { let sel = doc.selection.clone(); + let frames = content_knots.then(|| { + let mut f = Frames::new(); + if let Some(l) = doc.layer(id) { + source_frames(doc, l, &mut f); + } + f + }); let is_group = doc.layer(id).is_some_and(Layer::is_group); let group = crate::transform_cmds::group_locks(doc, id); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?; + warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp, frames.as_ref())?; let snapshot = doc.clone(); if let Some(l) = doc.layer_mut(id) { crate::transform_cmds::refresh_text(&snapshot, l); @@ -462,7 +546,7 @@ fn grid(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; + const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##; const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##; vec![ From dd258cccf9edaf4bcf6084b0bddfe1f84414c95b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:27:07 +0200 Subject: [PATCH 084/223] Type: faux bold keeps antialiased edges at small sizes (#4130) (#4360) * Type: faux bold keeps antialiased edges at small sizes (#4130) Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max. Co-Authored-By: Claude Opus 5.5 * Type: faux bold ring radius tuned so stems keep their weight (#4130) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/text/src/render.rs | 57 ++++++++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 35 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index acd9d51132..44165461aa 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -14,8 +14,8 @@ use crate::warp::Warp; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; -/// Faux-bold dilation radius as a fraction of the font size. -pub const FAUX_BOLD_RADIUS: f32 = 0.018; +/// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). +pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; @@ -53,6 +53,30 @@ impl LineSink for WarpSink<'_, S> { /// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { + draw_pass(layout, transform, sink, only_color, warp, Pass::All); +} + +/// Which part of the layout [`draw_pass`] emits. Faux bold draws each glyph at several offsets; +/// summing those into one coverage buffer saturates the antialiased edge pixels (hard, pixelated +/// stems), so the rasteriser draws every offset into its own buffer and merges them with `max`. +#[derive(Clone, Copy, PartialEq)] +enum Pass { + /// Everything, all offsets (bounds, outlines). + All, + /// Non-bold glyphs and decorations. + Plain, + /// Bold glyphs at one offset (index into [`BOLD_OFFSETS`]). + Bold(usize), +} + +const BOLD_OFFSETS: [(f64, f64); 8] = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)]; + +fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool { + let face_bold = layout.faces.get(g.face as usize).is_some_and(|f| f.embolden); + layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold +} + +fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -69,8 +93,17 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c let glyph = glyph_xform(layout, g, face.skew_deg); let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; - let offsets: &[(f64, f64)] = - if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] }; + let single = [(0.0, 0.0)]; + let offsets: &[(f64, f64)] = match (pass, bold) { + (Pass::Plain, true) => continue, + (Pass::Bold(_), false) => continue, + (Pass::Bold(k), true) => match BOLD_OFFSETS.get(k..=k) { + Some(o) => o, + None => continue, + }, + (Pass::All, true) => &BOLD_OFFSETS, + (_, false) => &single, + }; for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); @@ -91,6 +124,9 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c } } } + if matches!(pass, Pass::Bold(_)) { + return; + } for d in &layout.decorations { let st = &layout.styles[d.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -199,8 +235,17 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } for c in &colors { let mut cov = Coverage::new(w, h); - draw(layout, &xf, &mut cov, Some(c), warp); - let cov = cov.finish(); + draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); + let mut cov = cov.finish(); + if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + for k in 0..BOLD_OFFSETS.len() { + let mut bc = Coverage::new(w, h); + draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); + for (a, b) in cov.iter_mut().zip(bc.finish()) { + *a = a.max(b); + } + } + } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); for (i, &cv) in cov.iter().enumerate() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d502385f07..c21d5c2682 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -396,6 +396,41 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one +/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the +/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. +#[test] +fn faux_bold_keeps_antialiased_edges_at_small_sizes() { + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 12.0, ..Default::default() }; + let stats = |e: &mut TextEngine, s: CharStyle| { + let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 20.0), ..styled("Bold Test", s) }, 72.0, PixelFormat::RGBA8); + let (w, h) = (r.rect.width() as usize, r.rect.height() as usize); + let mut partial = 0usize; + for y in 0..h { + for x in 0..w { + let a = alpha_sum( + &r.surface, + photocraft_geom::Rect::new(r.rect.x0 + x as i32, r.rect.y0 + y as i32, r.rect.x0 + x as i32 + 1, r.rect.y0 + y as i32 + 1), + ); + if a > 0.05 && a < 0.95 { + partial += 1; + } + } + } + (alpha_sum(&r.surface, r.rect), partial) + }; + let (plain_ink, plain_partial) = stats(&mut e, base.clone()); + let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base }); + assert!(bold_ink > plain_ink, "bold must add ink"); + // Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this + // text at 12 px (plain 138.0). The antialiased version must stay close to that weight. + const MAIN_BOLD_INK: f64 = 282.7; + assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}"); +} + /// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling. #[test] fn faux_bold_scales_with_glyph_scaling() { From 8157d1a8517b381384aa63397c431ff4b0018c15 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Tue, 13 Oct 2026 03:27:12 +0200 Subject: [PATCH 085/223] Free Transform: refuse outputs over a pixel budget instead of building them (#4293) A large scale (e.g. 200x on a thin column) made the warp fill every tile of a result up to 2^21 px on a side, enough to exhaust memory. Free Transform now prices each warp before building anything and refuses with an allocation-style error ("...; the document was not changed") once a result would exceed MAX_TRANSFORM_PIXELS (2^30, the same bound as Puppet Warp's MAX_WARP_PIXELS). Only the result's size counts, so moving content far off the canvas still works, and nothing is clipped. The check covers the layer, a smart object's cache and filter mask, linked masks, a lone mask/channel target and the selection outline, and runs over the whole layer tree before any layer is changed. Transform Selection gets the same check. The transform box now stays open when a commit is refused. Part of #3773 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 44 ++++-- crates/engine/src/allocation.rs | 2 +- crates/engine/src/select_extra_cmds.rs | 1 + crates/engine/src/transform_cmds.rs | 190 ++++++++++++++++++++++++- crates/ui-egui/src/transform_tool.rs | 63 ++++++-- 5 files changed, 267 insertions(+), 33 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 1480ec2b3e..2584a877df 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -192,6 +192,30 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse() } +/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners +/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to +/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + if src_rect.is_empty() || h.inverse().is_none() { + return None; + } + let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)); + corner_bounds(&corners) +} + +fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { + if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) { + return None; + } + let lim = (1 << 20) as f64; + let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + Some(Rect::new(bx0, by0, bx1, by1)) +} + fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option) -> Result { let mut fmt = src.format(); let converted; @@ -234,21 +258,13 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]); let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0)); let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs())); - let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()); - let lim = 1 << 20; - let bounds = || { - let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - Rect::new(bx0, by0, bx1, by1) - }; - let dst = match clip { + let bounds = corner_bounds(&corners); + let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. - Some(c) if finite && side.is_some() => bounds().intersect(&c), - Some(c) => c, - None if finite => bounds(), - None => return Ok(out), + (Some(c), Some(b)) if side.is_some() => b.intersect(&c), + (Some(c), _) => c, + (None, Some(b)) => b, + (None, None) => return Ok(out), }; if dst.is_empty() { return Ok(out); diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 322e3ebd22..7c29ecc8ee 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -48,7 +48,7 @@ pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft Ok(out) } -fn error(purpose: &str, detail: &str) -> EngineError { +pub(crate) fn error(purpose: &str, detail: &str) -> EngineError { EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed")) } diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index e2c9e70591..5feae1a75f 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -195,6 +195,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } let selected = s.edit("Transform Selection", |doc, _| { if let Some(sel) = &doc.selection { + crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?; doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); } Ok(doc.selection.is_some()) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 44926b09f3..a09366e6fd 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,6 +1,6 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_surface}; +use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; @@ -234,9 +234,81 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses +/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not +/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused +/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px +/// on a side before anything could fail. +pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30; + +/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one. +#[cfg(not(test))] +const BUDGET: u64 = MAX_TRANSFORM_PIXELS; +#[cfg(test)] +const BUDGET: u64 = 1 << 20; + +/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, +/// before anything is built. `what` names the surface in the message. +pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { + let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; + let (w, ht) = (u64::from(b.width()), u64::from(b.height())); + if w.saturating_mul(ht) > BUDGET { + let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); + return Err(crate::allocation::error("transforming pixels", &detail)); + } + Ok(()) +} + +/// [`check_budget`] for every warp [`transform_layer`] would make of `l` (for a group, of every +/// layer inside it), so a transform that doesn't fit is refused before any layer is built and a +/// group that partly fits isn't half-transformed. +fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> Result<()> { + if h.is_identity() { + return Ok(()); + } + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack = vec![(l, doc_sel)]; + while let Some((l, sel)) = stack.pop() { + match &l.content { + LayerContent::Group(g) => stack.extend(g.children.iter().map(|c| (c, None))), + LayerContent::Text(_) | LayerContent::Shape(_) => {} + LayerContent::Smart(sm) => { + if let Some(c) = &sm.cache { + check_budget(c.content_bounds(), h, "smart object")?; + } + if let Some(m) = &sm.filter_mask { + check_budget(m.surface.content_bounds(), h, "smart filter mask")?; + } + } + _ => { + if let Some(surf) = l.surface() { + check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + } + } + } + if let Some(m) = &l.mask + && m.linked + { + // With a selection only its region of the mask moves (`warp_gray_selected`). + let src = match sel.filter(|_| !moves_whole(l)) { + Some(sel) => sel.content_bounds(), + None => m.surface.content_bounds(), + }; + check_budget(src, h, "layer mask")?; + } + } + Ok(()) +} + /// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). +/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { + check_layer_budget(doc_sel, l, h)?; + apply_transform(doc_sel, group, l, h, affine, interp) +} + +fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; let locks = transform_locks(l, group, false); if locks.position || locks.all { @@ -245,7 +317,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L if h.is_identity() { if let LayerContent::Group(g) = &mut l.content { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp)?; } } return Ok(()); @@ -256,7 +328,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp)?; } } LayerContent::Text(t) => { @@ -501,10 +573,16 @@ fn transform(s: &mut Session, p: &Value) -> Result { let lone = lone.is_some(); s.edit("Free Transform", |doc, _| { let sel = doc.selection.clone(); + // The selection outline moves too: price it before anything is built. + if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { + check_budget(sel.content_bounds(), &h, "selection")?; + } if lone { // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. if !h.is_identity() { let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); + check_budget(src, &h, "mask or channel")?; *surf = match &sel { Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, None => warp_gray(surf, &h, interp)?, @@ -577,6 +655,112 @@ mod tests { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } + // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + + /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. + fn column() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + /// Stretch x by `k` about x = 10 (the column's left edge). + fn stretch(k: f64) -> Value { + json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"}) + } + + #[test] + fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() { + let mut s = column(); + let st = s.active().unwrap(); + let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len()); + // 200000 x 10 px of output: twice the test budget. + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.starts_with("not enough memory for transforming pixels"), "{err}"); + assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before, "the document is untouched"); + assert_eq!((st.revision, st.history.past_len()), (revision, steps)); + // Within the budget the same stretch still works, nothing clipped. + s.execute("edit.transform", stretch(1e4)).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(0, 2, 10_000, 10)); + } + + #[test] + fn a_group_with_one_layer_past_the_budget_is_not_half_transformed() { + let mut s = column(); + let wide = s.active().unwrap().active_layer.unwrap(); + // Over the column: a 1 px dot, which fits the budget stretched by the same amount. + s.execute("layer.new.layer", json!({})).unwrap(); + let dot = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]); + // Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px. + doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": wide.0})).unwrap(); + s.execute("layer.select", json!({"layer": dot.0, "mode": "add"})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let st = s.active().unwrap(); + assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2)); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 5e4; + assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing changed"); + // The layer-level check runs before any child is warped, so even the layer itself + // (not just the edit's copy of the document) is left as it was. + let mut l = st.doc.layer(group).unwrap().clone(); + let h = Homography([k, 0.0, -10.0 * k, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert!(transform_layer(None, Locks::default(), &mut l, &h, None, Interp::Nearest).is_err()); + assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed"); + } + + #[test] + fn moving_a_small_layer_far_off_the_canvas_is_within_budget() { + let mut s = session(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 900_000, -900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(900_010, -899_990, 900_030, -899_980)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -900_000, 900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept"); + } + + #[test] + fn selections_masks_and_channels_have_the_same_budget() { + // The selection outline of a layer transform. + let mut s = column(); + s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + // Transform Selection. + let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + // A targeted alpha channel. + let mut s = column(); + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut p = stretch(2e5); + p["target"] = json!({"channel": 0}); + let err = s.execute("edit.transform", p).unwrap_err().to_string(); + assert!(err.contains("transformed mask or channel would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + } + #[test] fn scale_via_quad_and_undo() { let mut s = session(); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index ecb19fff7b..0af28a81c5 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -669,6 +669,9 @@ pub fn warps_type(app: &PhotocraftApp) -> bool { fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.transform.as_ref().is_some_and(|t| t.quad != corners(t.rect)) { commit(app); + if app.ui.transform.is_some() { + return; // refused: the box stays as it was + } if let Err(e) = begin(app, ctx) { app.ui.status = e; return; @@ -954,7 +957,7 @@ pub fn commit(app: &mut PhotocraftApp) { } let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; - app.transform_preview = None; + let preview = app.transform_preview.take(); if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -968,12 +971,12 @@ pub fn commit(app: &mut PhotocraftApp) { None => p["quad"] = json!(t.quad), } if let Err(e) = app.run("select.transformSelection", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } - if let Some(target) = t.path { - let mut p = target; + if let Some(target) = &t.path { + let mut p = target.clone(); match &t.warp { Some(w) if w.is_identity() => return, Some(w) => p["warp"] = json!(w), @@ -988,7 +991,7 @@ pub fn commit(app: &mut PhotocraftApp) { }, } if let Err(e) = app.run("path.transform", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } @@ -1004,8 +1007,8 @@ pub fn commit(app: &mut PhotocraftApp) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); - if let Some(target) = t.target { - p["target"] = target; + if let Some(target) = &t.target { + p["target"] = target.clone(); } app.run("edit.transform", p) }; @@ -1015,17 +1018,19 @@ pub fn commit(app: &mut PhotocraftApp) { fold_made(app, made); } } - Err(e) => { - if made.is_some() - && let Some(document) = document - { - take_back_made(app, document, LayerId(t.layer)); - } - app.ui.status = e; - } + Err(e) => keep_open(app, t, preview, e), } } +/// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it +/// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or +/// a placed layer it made goes only when it is cancelled. +fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option, e: String) { + app.ui.transform = Some(t); + app.transform_preview = preview; + app.ui.status = e; +} + /// A transform whose layer or document went away (undo, close) ends silently; picking another tool /// or selecting another layer (Layers panel, ⌘-click, the control channel) applies it, so the box /// never stays on a layer that is no longer the active one (#1400). Checked every frame and before @@ -1055,6 +1060,10 @@ pub fn end_if_left(app: &mut PhotocraftApp) { || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) { commit(app); + // Picking another tool or layer can't leave a refused transform open: it ends, as Esc would. + if app.ui.transform.is_some() { + cancel(app); + } } } @@ -3413,6 +3422,30 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); } + /// A commit the engine refuses (here a collapsed box; a result over the pixel budget, #3773, is + /// refused the same way) shows the reason and keeps the box open, unchanged; picking another + /// tool then ends it. + #[test] + fn a_refused_commit_keeps_the_box_open() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let (revision, steps) = (app.session.active().unwrap().revision, app.session.active().unwrap().history.past_len()); + begin(&mut app, &ctx).unwrap(); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = [[16.0, 16.0]; 4]; + } + commit(&mut app); + let t = app.ui.transform.as_ref().expect("the box stays open"); + assert_eq!(t.quad, [[16.0, 16.0]; 4], "as the user left it"); + assert!(app.transform_preview.is_some(), "with its preview"); + assert!(app.ui.status.contains("collapses"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!((st.revision, st.history.past_len()), (revision, steps), "the document was not changed"); + app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush }; + end_if_left(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. From 465a663540249f8d2705963e5e06f72a9bc5b6b0 Mon Sep 17 00:00:00 2001 From: deadmeu Date: Tue, 13 Oct 2026 01:31:32 +0000 Subject: [PATCH 086/223] feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217) Draw the active layer's storage tiles over the canvas: a faint TILE_SIZE grid over the document, every stored tile filled and labelled tx,ty, tiles with identical pixels in one colour per group and unique tiles in blue. It shows how sparse a layer is, which tiles copy-on-write shares, and which ones .pcraft saves once. It has no menu item and isn't a View > Show item. The control channel and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports it as view.tile_grid, and View > Extras doesn't hide it. Grouping hashes each distinct tile allocation once, in parallel (or serially while jobs hold the pool, #1554), cached per document revision and active layer. A 36 MP layer of all-different tiles groups in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the ignored tile_grouping_36mp test reproduces this. Also documents tileGrid in docs/control-protocol.md and docs/development.md, and adds my contributors/people.toml entry. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/automation/src/server.rs | 3 +- crates/ui-egui/src/canvas.rs | 6 + crates/ui-egui/src/control.rs | 27 ++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tile_overlay.rs | 293 +++++++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 4 + docs/control-protocol.md | 1 + docs/development.md | 1 + 9 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tile_overlay.rs diff --git a/contributors/people.toml b/contributors/people.toml index 9a8bf4b4ce..f5b001204d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -140,3 +140,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.deadmeu] +sync_github_name = true diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 71c290cae7..74b3770dff 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -323,7 +323,8 @@ pub struct UiSetParams { /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, - /// the Content-Aware Move tool's Transform On Drop). Other fields are an error. + /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the + /// active layer's storage tiles). Other fields are an error. pub fields: Value, } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bfcb0d1a7f..3094ce6fa1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2823,6 +2823,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf)); } + // Developer tile grid (`ui.set {"tileGrid": true}`): the active layer's storage tiles. + // Not tied to View › Extras (⌘H), which hides only the View › Show overlays. + if app.ui.view.tile_grid { + crate::tile_overlay::paint(app, &ctx, &painter, &xf, idx, visible_doc_rect(&xf)); + } + // View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are // in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer. let layer_edges = painter.add(egui::Shape::Noop); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fb1f652576..ceacb71928 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; 34] = [ +pub const UI_SET_FIELDS: [&str; 35] = [ "tool", "panels", "dock", @@ -166,6 +166,7 @@ pub const UI_SET_FIELDS: [&str; 34] = [ "cropShield", "shapeStroke", "camTransformOnDrop", + "tileGrid", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -492,6 +493,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let eyedropper_ring = bool_field(p, "eyedropperRing")?; // Content-Aware Move's Transform On Drop checkbox. 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")?; // 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(|| { @@ -650,6 +653,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = cam_transform_on_drop { app.ui.tool_options.cam_transform_on_drop = on; } + if let Some(on) = tile_grid { + app.ui.view.tile_grid = on; + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } @@ -1858,6 +1864,25 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + #[test] + fn ui_set_toggles_the_developer_tile_grid() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.ui.view.tile_grid, "off by default"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": true}))["ok"], true); + assert!(app.ui.view.tile_grid); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["view"]["tile_grid"], true); + // Not a View › Show item: Extras doesn't hide it, and Show Extras Options doesn't list it. + crate::menus::invoke(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras && app.ui.view.tile_grid); + assert!(serde_json::to_value(app.ui.view.show).unwrap().get("tile_grid").is_none()); + let bad = call(&mut app, &ctx, "ui.set", json!({"tileGrid": "yes"})); + assert_eq!(bad["ok"], false, "{bad}"); + assert!(app.ui.view.tile_grid, "a rejected value changes nothing"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": false}))["ok"], true); + assert!(!app.ui.view.tile_grid); + } + #[test] fn ui_set_drives_the_eyedropper_options() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cef61293f8..a0aa5d4e9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -191,6 +191,7 @@ pub mod targa_options_ui; pub mod text_field_menu; pub mod theme; pub mod tiff_options_ui; +mod tile_overlay; mod timeline_ui; mod titlebar; pub mod tone; diff --git a/crates/ui-egui/src/tile_overlay.rs b/crates/ui-egui/src/tile_overlay.rs new file mode 100644 index 0000000000..03d8156dde --- /dev/null +++ b/crates/ui-egui/src/tile_overlay.rs @@ -0,0 +1,293 @@ +//! The developer tile grid: the active layer's storage tiles over the canvas, turned on with the +//! control channel's `ui.set {"tileGrid": true}` ([`crate::view_cmds::ViewOptions::tile_grid`]). +//! It shows how sparse a layer is, which tiles copy-on-write shares, and which ones `.pcraft` +//! saves once. +//! +//! A layer's pixels live in a sparse [`Surface`] of [`TILE_SIZE`] tiles; cells with no stored +//! tile read as the surface's default pixel and cost nothing. The overlay draws the tile grid +//! faintly over the document, fills every stored tile, and labels it `tx,ty` once it is large +//! enough on screen. Tiles with identical pixels share a hue; a tile whose pixels appear nowhere +//! else is plain blue. +//! +//! Grouping reads every stored tile, so it is cached per document revision and active layer, +//! never redone per frame (AGENTS.md rule 8). Tile identity can't stand in for the revision: a +//! tile nothing else shares is written in place. Each allocation is hashed once, and tiles are +//! grouped by that 64-bit hash of their bytes. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Painter, Shape, Stroke}; +use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use rayon::prelude::*; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +/// Outline and fill of a tile whose pixels appear nowhere else in the layer. +const UNIQUE: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); +/// Alpha of a stored tile's fill; low so the image stays readable underneath. +const FILL_ALPHA: u8 = 40; +/// The grid over cells with no stored tile: a translucent mid grey, seen on light and dark pixels. +const EMPTY_LINE: Color32 = Color32::from_rgba_premultiplied(32, 32, 32, 64); +/// Tile labels show once a tile is at least this many points across. +const LABEL_MIN: f32 = 56.0; + +/// One stored tile and the group of tiles it shares its pixels with (`None` = unique). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileInfo { + pub coord: TileCoord, + pub group: Option, +} + +/// The tiles of one layer, valid for one document revision. +#[derive(Clone)] +struct Cached { + revision: u64, + tiles: Arc>, +} + +/// A tile's allocation as a plain address, so it can cross to the Rayon pool. +fn ptr(t: &Arc) -> usize { + Arc::as_ptr(t) as usize +} + +fn content_hash(t: &Tile) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + t.bytes().hash(&mut h); + h.finish() +} + +/// Every stored tile of `s` with its shared-pixels group. Groups are numbered in tile order, so +/// the same surface always gets the same colours. `parallel` hashes on the Rayon pool; pass +/// false while jobs hold it, so the UI thread never waits on them (#1554). +pub fn tile_infos(s: &Surface, parallel: bool) -> Vec { + // Hash each distinct allocation once; copy-on-write clones share one. + let mut by_ptr: HashMap> = HashMap::new(); + for (_, t) in s.tiles() { + by_ptr.entry(ptr(t)).or_insert(t); + } + let distinct: Vec<(usize, &Arc)> = by_ptr.into_iter().collect(); + let hashed: Vec<(usize, u64)> = if parallel { + distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect() + } else { + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; + let hash_of: HashMap = hashed.into_iter().collect(); + let mut count: HashMap = HashMap::new(); + for (_, t) in s.tiles() { + if let Some(h) = hash_of.get(&ptr(t)) { + *count.entry(*h).or_default() += 1; + } + } + let mut group_of: HashMap = HashMap::new(); + s.tiles() + .map(|(c, t)| { + let group = hash_of.get(&ptr(t)).filter(|h| count.get(h).is_some_and(|&n| n > 1)).map(|h| { + let next = group_of.len(); + *group_of.entry(*h).or_insert(next) + }); + TileInfo { coord: *c, group } + }) + .collect() +} + +/// Hues (0..1) left to unique tiles: shared groups never land in the blues, nor in the +/// blue-violets that read as blue next to them ([`UNIQUE`] is about 0.58). +const BLUE_HUES: (f32, f32) = (0.45, 0.75); +/// Gap kept from either end of the allowed range, as a share of it. +const HUE_MARGIN: f32 = 0.05; + +/// The hue of shared group `g`: golden-ratio steps keep neighbouring group numbers far apart, +/// spread over the wheel outside [`BLUE_HUES`] and clear of its edges. +fn group_hue(g: usize) -> f32 { + let step = (g as f32 * 0.618_034).fract(); + let span = 1.0 - (BLUE_HUES.1 - BLUE_HUES.0); + (BLUE_HUES.1 + (HUE_MARGIN + step * (1.0 - 2.0 * HUE_MARGIN)) * span).fract() +} + +/// The colour of a tile group: blue for unique tiles, an evenly spread hue per shared group. +pub fn group_color(group: Option) -> Color32 { + let Some(g) = group else { return UNIQUE }; + egui::ecolor::Hsva::new(group_hue(g), 0.85, 0.95, 1.0).into() +} + +/// The tile cells of the grid that touch `r`, as an inclusive (tx0, ty0, tx1, ty1) range. +pub fn cell_range(r: DRect) -> Option<(i32, i32, i32, i32)> { + if r.is_empty() { + return None; + } + let (a, b) = (TileCoord::containing(r.x0, r.y0), TileCoord::containing(r.x1 - 1, r.y1 - 1)); + Some((a.tx, a.ty, b.tx, b.ty)) +} + +fn quad(xf: &ViewXform, r: DRect) -> [egui::Pos2; 4] { + let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + [xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)] +} + +/// Draw the active layer's tiles of document `idx`. `visible` is the document area in view. +pub fn paint(app: &PhotocraftApp, ctx: &egui::Context, painter: &Painter, xf: &ViewXform, idx: usize, visible: DRect) { + let Some(st) = app.session.documents().get(idx) else { return }; + let Some(surface) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()) else { return }; + let revision = st.revision; + let mem = egui::Id::new(("pc-tile-overlay", st.doc.id.0, st.active_layer.map(|l| l.0))); + let cached = ctx.data(|d| d.get_temp::(mem)).filter(|c| c.revision == revision); + let tiles = match cached { + Some(c) => c.tiles, + None => { + let tiles = Arc::new(tile_infos(surface, !app.session.has_jobs())); + ctx.data_mut(|d| d.insert_temp(mem, Cached { revision, tiles: tiles.clone() })); + tiles + } + }; + + // The grid over the document, so the cells without a stored tile show too. + let bounds = st.doc.bounds(); + if let Some((tx0, ty0, tx1, ty1)) = cell_range(visible.intersect(&bounds)) { + let line = Stroke::new(1.0, EMPTY_LINE); + let edge = |t: i32| t.saturating_mul(TILE_SIZE); + let (top, bottom) = (bounds.y0.max(edge(ty0)) as f32, bounds.y1.min(edge(ty1.saturating_add(1))) as f32); + let (left, right) = (bounds.x0.max(edge(tx0)) as f32, bounds.x1.min(edge(tx1.saturating_add(1))) as f32); + for tx in tx0..=tx1.saturating_add(1) { + let x = edge(tx).clamp(bounds.x0, bounds.x1) as f32; + painter.line_segment([xf.to_screen(x, top), xf.to_screen(x, bottom)], line); + } + for ty in ty0..=ty1.saturating_add(1) { + let y = edge(ty).clamp(bounds.y0, bounds.y1) as f32; + painter.line_segment([xf.to_screen(left, y), xf.to_screen(right, y)], line); + } + } + + // Stored tiles, which may lie past the canvas edge. + let size = TILE_SIZE as f32 * xf.zoom; + for t in tiles.iter() { + let r = t.coord.rect(); + if r.intersect(&visible).is_empty() { + continue; + } + let c = group_color(t.group); + let pts = quad(xf, r).to_vec(); + let fill = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), FILL_ALPHA); + painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + painter.add(Shape::closed_line(pts, Stroke::new(1.0, c))); + if size >= LABEL_MIN { + let mid = xf.to_screen(r.x0 as f32 + TILE_SIZE as f32 / 2.0, r.y0 as f32 + TILE_SIZE as f32 / 2.0); + let label = format!("{},{}", t.coord.tx, t.coord.ty); + let font = FontId::monospace((size / 8.0).clamp(10.0, 16.0)); + // A dark drop shadow keeps the label readable over light pixels. + painter.text(mid + egui::vec2(1.0, 1.0), Align2::CENTER_CENTER, &label, font.clone(), Color32::from_black_alpha(180)); + painter.text(mid, Align2::CENTER_CENTER, label, font, Color32::WHITE); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + /// The document rect of tile cell (tx, ty). + fn cell(tx: i32, ty: i32) -> DRect { + TileCoord::new(tx, ty).rect() + } + + #[test] + fn empty_surface_has_no_tiles() { + assert!(tile_infos(&Surface::new(PixelFormat::RGBA8), true).is_empty()); + } + + #[test] + fn identical_pixels_share_a_group_and_unique_tiles_have_none() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + identical_pixels_share_a_group_in(fmt); + } + } + + fn identical_pixels_share_a_group_in(fmt: PixelFormat) { + let mut s = Surface::new(fmt); + // Two separately allocated tiles with the same red pixels, as after loading a .pcraft. + s.fill_rect(cell(0, 0), &[1.0, 0.0, 0.0, 1.0]); + s.fill_rect(cell(2, 0), &[1.0, 0.0, 0.0, 1.0]); + // One shared allocation in two cells (copy-on-write), and a lone green tile. + let blue = s.solid_tile(&[0.0, 0.0, 1.0, 1.0]); + s.put_tiles([(TileCoord::new(0, 1), blue.clone()), (TileCoord::new(1, 1), blue)]); + s.fill_rect(cell(1, 0), &[0.0, 1.0, 0.0, 1.0]); + for parallel in [true, false] { + let infos = tile_infos(&s, parallel); + let group = |tx, ty| infos.iter().find(|i| i.coord == TileCoord::new(tx, ty)).map(|i| i.group); + assert_eq!(infos.len(), 5); + assert_eq!(group(1, 0), Some(None), "green is unique"); + let (red, blue) = (group(0, 0).flatten(), group(0, 1).flatten()); + assert!(red.is_some() && blue.is_some() && red != blue); + assert_eq!(group(2, 0).flatten(), red); + assert_eq!(group(1, 1).flatten(), blue); + } + } + + #[test] + fn groups_get_distinct_colours_and_unique_tiles_stay_blue() { + assert_eq!(group_color(None), UNIQUE); + let colours: Vec = (0..6).map(|g| group_color(Some(g))).collect(); + for (i, a) in colours.iter().enumerate() { + assert_ne!(*a, UNIQUE); + assert!(colours.iter().skip(i + 1).all(|b| b != a)); + } + // Huge group numbers still map to a colour. + let _ = group_color(Some(usize::MAX)); + } + + #[test] + fn shared_groups_never_take_the_blue_of_unique_tiles() { + for g in (0..10_000).chain([usize::MAX]) { + let h = group_hue(g); + assert!((0.0..1.0).contains(&h), "group {g}: hue {h}"); + assert!(!(BLUE_HUES.0..=BLUE_HUES.1).contains(&h), "group {g}: hue {h} is blue"); + // Clear of the range's edges too, where hues still read as blue. + assert!((h - BLUE_HUES.1).abs() > 0.01 && (h - BLUE_HUES.0).abs() > 0.01, "group {g}: hue {h} is at the edge"); + } + } + + /// `cargo test --release -p photocraft-ui-egui --lib tile_grouping_36mp -- --ignored --nocapture` + #[test] + #[ignore = "benchmark"] + fn tile_grouping_36mp() { + let (w, h) = (7360, 4912); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + // Every tile different, the worst case: each one is hashed and none share. + let mut s = Surface::new(fmt); + let Some((tx1, ty1)) = cell_range(DRect::from_xywh(0, 0, w, h)).map(|(_, _, tx1, ty1)| (tx1, ty1)) else { return }; + for ty in 0..=ty1 { + for tx in 0..=tx1 { + let seed = (tx * 31 + ty * 17) as usize; + for (i, b) in s.tile_mut(TileCoord::new(tx, ty)).bytes_mut().iter_mut().enumerate() { + *b = (i ^ seed) as u8; + } + } + } + for parallel in [true, false] { + let mut best = f64::MAX; + for _ in 0..8 { + let t = std::time::Instant::now(); + std::hint::black_box(tile_infos(&s, parallel)); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + } + let mode = if parallel { "parallel" } else { "serial" }; + println!("36 MP {fmt:?}, {} tiles, {mode}: {best:.1} ms", s.tile_count()); + } + } + } + + #[test] + fn cell_range_covers_the_cells_a_rect_touches() { + let t = TILE_SIZE; + // Exactly one cell, then a partial cell past whole ones (a canvas not a multiple of it). + assert_eq!(cell_range(cell(0, 0)), Some((0, 0, 0, 0))); + assert_eq!(cell_range(DRect::new(0, 0, 7 * t + t / 2, 4 * t + 1)), Some((0, 0, 7, 4))); + // One pixel either side of a cell edge, including left of the origin. + assert_eq!(cell_range(DRect::new(-1, t - 1, t + 1, t + 4)), Some((-1, 0, 1, 1))); + assert_eq!(cell_range(DRect::EMPTY), None); + } +} diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 33fc6affc5..585e5dd074 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -96,6 +96,9 @@ pub struct ViewOptions { pub middle_eastern_composer: bool, /// Parameters from the last successfully applied New Guide Layout dialog. pub guide_layout: Value, + /// Developer overlay of the active layer's storage tiles (tile_overlay.rs), set with the + /// control channel's `ui.set {"tileGrid": true}`. + pub tile_grid: bool, } /// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions). @@ -121,6 +124,7 @@ impl Default for ViewOptions { language_features: "defaultFeatures".into(), middle_eastern_composer: false, guide_layout: default_guide_layout(), + tile_grid: false, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f698ee2a6c..166a61210c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,6 +52,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}` - `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`. - 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. diff --git a/docs/development.md b/docs/development.md index 73a3bde0bf..6be2bd050b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,6 +144,7 @@ See `docs/control-protocol.md` for every method. Tips: - **macOS does not render occluded windows.** `ui.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before other visual checks. - **Capture only our own window.** For native captures (to see the real title bar and traffic lights), capture by window id (`screencapture -l `), never a screen region, which can grab other apps. - **Pointer gestures:** `ui.pointer` takes events in *document* coordinates and runs them through the same tool state machine as the mouse. +- **Seeing tile storage:** `ui.set {"tileGrid": true}` overlays the active layer's stored tiles, which shows how sparse a layer is and which tiles share pixels (`ui-egui/src/tile_overlay.rs`). ## Headless CLI From ccfc878a1484a9f630b4284c318a7ef09fc79a10 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:34:54 +0200 Subject: [PATCH 087/223] Camera Raw Filter: save and load settings as .xmp (#4279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Camera Raw Filter: save and load settings as .xmp (#4274) [WIP] * Camera Raw Filter: Save Settings… and Load Settings… (.xmp) (#4274) Adds Save Settings… and Load Settings… to the Camera Raw Filter dialog's footer. They write and read Adobe Camera Raw settings files: the crs: namespace on x:xmpmeta/rdf:RDF/rdf:Description, as attributes or elements. photocraft_io::camera_raw_xmp maps every control the dialog has: - Basic (Exposure2012 … Saturation, IncrementalTemperature/Tint) - the parametric and point tone curves (ToneCurvePV2012[Red|Green|Blue]) - the HSL mixer - Color Grading (including the SplitToning* names for the shadow and highlight wheels) - sharpening and noise reduction - grain - post-crop vignetting Loading applies only what the file holds over the current settings, as Camera Raw does. Values are clamped to the slider ranges. Unsupported settings (lens, calibration, geometry, defringe) are ignored, and the status bar counts them when they aren't neutral. Malformed files fail with a message and leave the settings unchanged. The control channel takes {"ui":{"saveSettings"|"loadSettings":true}}. Translations are added for all 17 catalogs. Fixes #4274 * Add Turkish and Hungarian rows for the Camera Raw settings strings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/camera_raw_xmp.rs | 423 ++++++++++++++++++++++++++++ crates/io/src/lib.rs | 1 + crates/ui-egui/src/camera_raw_ui.rs | 141 +++++++++- crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + 22 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 crates/io/src/camera_raw_xmp.rs diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs new file mode 100644 index 0000000000..159e298a06 --- /dev/null +++ b/crates/io/src/camera_raw_xmp.rs @@ -0,0 +1,423 @@ +//! Camera Raw Filter › Save Settings… / Load Settings… (#4274): Adobe Camera Raw settings files +//! (`.xmp`), the `crs:` namespace (`http://ns.adobe.com/camera-raw-settings/1.0/`) on an +//! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements. +//! +//! Loading applies only the settings the file holds, over the current ones, as Camera Raw's +//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration, +//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported +//! control, so loading the file in Photoshop reproduces the dialog. +//! +//! White balance: a Camera Raw Filter on a rendered image uses the relative +//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature +//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no +//! counterpart and are ignored. + +use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; + +/// The Camera Raw settings namespace. +pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/"; +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +/// Largest settings file read; real ones are a few kilobytes. +pub const MAX_XMP_BYTES: usize = 4 * 1024 * 1024; + +type Field = fn(&mut CameraRaw) -> &mut f32; + +/// `crs:` scalar name, PhotoCraft field, and the slider range values are clamped to. +const SCALARS: &[(&str, Field, f32, f32)] = &[ + ("IncrementalTemperature", |p| &mut p.temperature, -100.0, 100.0), + ("IncrementalTint", |p| &mut p.tint, -100.0, 100.0), + ("Exposure2012", |p| &mut p.exposure, -5.0, 5.0), + ("Contrast2012", |p| &mut p.contrast, -100.0, 100.0), + ("Highlights2012", |p| &mut p.highlights, -100.0, 100.0), + ("Shadows2012", |p| &mut p.shadows, -100.0, 100.0), + ("Whites2012", |p| &mut p.whites, -100.0, 100.0), + ("Blacks2012", |p| &mut p.blacks, -100.0, 100.0), + ("Texture", |p| &mut p.texture, -100.0, 100.0), + ("Clarity2012", |p| &mut p.clarity, -100.0, 100.0), + ("Dehaze", |p| &mut p.dehaze, -100.0, 100.0), + ("Vibrance", |p| &mut p.vibrance, -100.0, 100.0), + ("Saturation", |p| &mut p.saturation, -100.0, 100.0), + ("ParametricShadows", |p| &mut p.curve_shadows, -100.0, 100.0), + ("ParametricDarks", |p| &mut p.curve_darks, -100.0, 100.0), + ("ParametricLights", |p| &mut p.curve_lights, -100.0, 100.0), + ("ParametricHighlights", |p| &mut p.curve_highlights, -100.0, 100.0), + ("ParametricShadowSplit", |p| &mut p.curve_splits[0], 0.0, 100.0), + ("ParametricMidtoneSplit", |p| &mut p.curve_splits[1], 0.0, 100.0), + ("ParametricHighlightSplit", |p| &mut p.curve_splits[2], 0.0, 100.0), + ("HueAdjustmentRed", |p| &mut p.hsl_hue[0], -100.0, 100.0), + ("HueAdjustmentOrange", |p| &mut p.hsl_hue[1], -100.0, 100.0), + ("HueAdjustmentYellow", |p| &mut p.hsl_hue[2], -100.0, 100.0), + ("HueAdjustmentGreen", |p| &mut p.hsl_hue[3], -100.0, 100.0), + ("HueAdjustmentAqua", |p| &mut p.hsl_hue[4], -100.0, 100.0), + ("HueAdjustmentBlue", |p| &mut p.hsl_hue[5], -100.0, 100.0), + ("HueAdjustmentPurple", |p| &mut p.hsl_hue[6], -100.0, 100.0), + ("HueAdjustmentMagenta", |p| &mut p.hsl_hue[7], -100.0, 100.0), + ("SaturationAdjustmentRed", |p| &mut p.hsl_sat[0], -100.0, 100.0), + ("SaturationAdjustmentOrange", |p| &mut p.hsl_sat[1], -100.0, 100.0), + ("SaturationAdjustmentYellow", |p| &mut p.hsl_sat[2], -100.0, 100.0), + ("SaturationAdjustmentGreen", |p| &mut p.hsl_sat[3], -100.0, 100.0), + ("SaturationAdjustmentAqua", |p| &mut p.hsl_sat[4], -100.0, 100.0), + ("SaturationAdjustmentBlue", |p| &mut p.hsl_sat[5], -100.0, 100.0), + ("SaturationAdjustmentPurple", |p| &mut p.hsl_sat[6], -100.0, 100.0), + ("SaturationAdjustmentMagenta", |p| &mut p.hsl_sat[7], -100.0, 100.0), + ("LuminanceAdjustmentRed", |p| &mut p.hsl_lum[0], -100.0, 100.0), + ("LuminanceAdjustmentOrange", |p| &mut p.hsl_lum[1], -100.0, 100.0), + ("LuminanceAdjustmentYellow", |p| &mut p.hsl_lum[2], -100.0, 100.0), + ("LuminanceAdjustmentGreen", |p| &mut p.hsl_lum[3], -100.0, 100.0), + ("LuminanceAdjustmentAqua", |p| &mut p.hsl_lum[4], -100.0, 100.0), + ("LuminanceAdjustmentBlue", |p| &mut p.hsl_lum[5], -100.0, 100.0), + ("LuminanceAdjustmentPurple", |p| &mut p.hsl_lum[6], -100.0, 100.0), + ("LuminanceAdjustmentMagenta", |p| &mut p.hsl_lum[7], -100.0, 100.0), + // Color Grading keeps the split-toning names for the shadow and highlight wheels' hue and + // saturation, and the balance. + ("SplitToningShadowHue", |p| &mut p.grade_shadows.hue, 0.0, 360.0), + ("SplitToningShadowSaturation", |p| &mut p.grade_shadows.sat, 0.0, 100.0), + ("ColorGradeShadowLum", |p| &mut p.grade_shadows.lum, -100.0, 100.0), + ("ColorGradeMidtoneHue", |p| &mut p.grade_midtones.hue, 0.0, 360.0), + ("ColorGradeMidtoneSat", |p| &mut p.grade_midtones.sat, 0.0, 100.0), + ("ColorGradeMidtoneLum", |p| &mut p.grade_midtones.lum, -100.0, 100.0), + ("SplitToningHighlightHue", |p| &mut p.grade_highlights.hue, 0.0, 360.0), + ("SplitToningHighlightSaturation", |p| &mut p.grade_highlights.sat, 0.0, 100.0), + ("ColorGradeHighlightLum", |p| &mut p.grade_highlights.lum, -100.0, 100.0), + ("ColorGradeGlobalHue", |p| &mut p.grade_global.hue, 0.0, 360.0), + ("ColorGradeGlobalSat", |p| &mut p.grade_global.sat, 0.0, 100.0), + ("ColorGradeGlobalLum", |p| &mut p.grade_global.lum, -100.0, 100.0), + ("ColorGradeBlending", |p| &mut p.grade_blending, 0.0, 100.0), + ("SplitToningBalance", |p| &mut p.grade_balance, -100.0, 100.0), + ("Sharpness", |p| &mut p.sharpen_amount, 0.0, 150.0), + ("SharpenRadius", |p| &mut p.sharpen_radius, 0.5, 3.0), + ("SharpenDetail", |p| &mut p.sharpen_detail, 0.0, 100.0), + ("SharpenEdgeMasking", |p| &mut p.sharpen_masking, 0.0, 100.0), + ("LuminanceSmoothing", |p| &mut p.noise_luminance, 0.0, 100.0), + ("LuminanceNoiseReductionDetail", |p| &mut p.noise_luminance_detail, 0.0, 100.0), + ("ColorNoiseReduction", |p| &mut p.noise_color, 0.0, 100.0), + ("ColorNoiseReductionDetail", |p| &mut p.noise_color_detail, 0.0, 100.0), + ("GrainAmount", |p| &mut p.grain_amount, 0.0, 100.0), + ("GrainSize", |p| &mut p.grain_size, 0.0, 100.0), + ("GrainFrequency", |p| &mut p.grain_roughness, 0.0, 100.0), + ("PostCropVignetteAmount", |p| &mut p.vignette_amount, -100.0, 100.0), + ("PostCropVignetteMidpoint", |p| &mut p.vignette_midpoint, 0.0, 100.0), + ("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0), + ("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0), + ("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0), +]; + +type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>; +const CURVES: &[(&str, Curve)] = &[ + ("ToneCurvePV2012", |p| &mut p.point_curve), + ("ToneCurvePV2012Red", |p| &mut p.point_curve_red), + ("ToneCurvePV2012Green", |p| &mut p.point_curve_green), + ("ToneCurvePV2012Blue", |p| &mut p.point_curve_blue), +]; + +const STYLES: &[(&str, &str)] = &[("1", "highlightPriority"), ("2", "colorPriority"), ("3", "paintOverlay")]; + +/// Bookkeeping fields Camera Raw writes that carry no setting. +const METADATA: &[&str] = &[ + "Version", + "ProcessVersion", + "HasSettings", + "HasCrop", + "WhiteBalance", + "ToneCurveName2012", + "CameraProfile", + "PresetType", + "UUID", + "SupportsAmount", + "SupportsColor", + "SupportsMonochrome", + "SupportsHighDynamicRange", + "SupportsNormalDynamicRange", + "SupportsSceneReferred", + "SupportsOutputReferred", + "CameraModelRestriction", + "Copyright", + "ContactInfo", + "Name", + "ShortName", + "SortName", + "Group", + "Description", +]; + +/// What [`load`] applied. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LoadReport { + /// How many settings were applied. + pub applied: usize, + /// `crs:` settings without a PhotoCraft control (set to something other than their neutral + /// value), or with an unusable value, in file order. + pub ignored: Vec, +} + +fn number(s: &str) -> Option { + let s = s.trim(); + let v: f32 = s.strip_prefix('+').unwrap_or(s).parse().ok()?; + v.is_finite().then_some(v) +} + +/// `"0, 0"` items of an `rdf:Seq` → curve points. +fn curve(node: roxmltree::Node<'_, '_>) -> Option> { + let seq = node.children().find(|c| c.is_element() && c.tag_name().namespace() == Some(RDF_NS) && c.tag_name().name() == "Seq")?; + let mut points = Vec::new(); + for li in seq.children().filter(|c| c.is_element() && c.tag_name().name() == "li") { + let text = li.text().unwrap_or(""); + let (x, y) = text.split_once(',')?; + points.push([number(x)?, number(y)?]); + } + validate_curve(&points).ok()?; + // Camera Raw's linear curve is the identity's two end points; PhotoCraft's is empty. + if points == [[0.0, 0.0], [255.0, 255.0]] { + points.clear(); + } + Some(points) +} + +/// An unsupported setting at its neutral value changes nothing, so it isn't reported: zero, +/// `False`, and the few neutral values that aren't zero. +fn neutral(name: &str, value: &str) -> bool { + let v = value.trim(); + if v.eq_ignore_ascii_case("false") || number(v) == Some(0.0) { + return true; + } + match name { + "GrainSeed" => true, + "VignetteMidpoint" => number(v) == Some(50.0), + "PerspectiveScale" => number(v) == Some(100.0), + _ => name.starts_with("Defringe") && name.contains("Hue"), + } +} + +fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { + if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { + match number(value) { + Some(v) => { + *field(params) = v.clamp(*lo, *hi); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if name == "PostCropVignetteStyle" { + match STYLES.iter().find(|(n, _)| *n == value.trim()) { + Some((_, style)) => { + params.vignette_style = (*style).into(); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if !METADATA.contains(&name) && !neutral(name, value) { + report.ignored.push(name.to_string()); + } +} + +/// Apply a Camera Raw settings file over `params`. Fails on input that isn't XMP or holds no +/// Camera Raw settings; never panics on malformed input. +pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result { + if bytes.len() > MAX_XMP_BYTES { + return Err("the settings file is too large".into()); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a Camera Raw settings file (not UTF-8 text)".to_string())?; + // Some writers put a byte order mark or an `` wrapper first; roxmltree takes both. + let text = text.trim_start_matches('\u{feff}'); + let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a Camera Raw settings file ({e})"))?; + let mut next = params.clone(); + let mut report = LoadReport::default(); + let mut found = false; + for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { + for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { + found = true; + apply(&mut next, &mut report, a.name(), a.value()); + } + for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { + found = true; + let name = c.tag_name().name(); + if let Some((_, field)) = CURVES.iter().find(|(n, _)| *n == name) { + match curve(c) { + Some(points) => { + *field(&mut next) = points; + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if c.children().any(|g| g.is_element()) { + // Structured settings (masks, looks, lens profiles…) have no counterpart. + if !METADATA.contains(&name) { + report.ignored.push(name.to_string()); + } + } else { + apply(&mut next, &mut report, name, c.text().unwrap_or("")); + } + } + } + if !found { + return Err("no Camera Raw settings in the file".into()); + } + *params = next; + Ok(report) +} + +fn signed(v: f32, decimals: usize) -> String { + let s = format!("{v:.decimals$}"); + if v > 0.0 && !s.starts_with('+') { format!("+{s}") } else { s } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. +pub fn save(params: &CameraRaw) -> String { + let mut p = params.clone(); + let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; + let mut out = String::from( + "\n \n signed(v, 2), + "SharpenRadius" => signed(v, 1), + _ if *lo < 0.0 => signed(v.round(), 0), + _ => format!("{:.0}", v.round()), + }; + out.push_str(&format!(" crs:{name}=\"{text}\"\n")); + } + let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); + out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); + for (name, field) in CURVES { + let points = field(&mut p); + let points = if points.is_empty() { vec![[0.0, 0.0], [255.0, 255.0]] } else { points.clone() }; + out.push_str(&format!(" \n \n")); + for [x, y] in points { + out.push_str(&format!(" {}, {}\n", x.round(), y.round())); + } + out.push_str(&format!(" \n \n")); + } + out.push_str(" \n \n\n"); + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// A hand-written settings file laid out as Photoshop 26's Camera Raw Filter saves one (the + /// field set and order follow Camera Raw 18; the values are synthetic), plus element-form + /// settings, which XMP allows and some writers use. + const FIXTURE: &[u8] = br#" + + + +12 + + + 0, 0 + 255, 255 + + + + + 0, 0 + 128, 140 + 255, 255 + + + + +"#; + + #[test] + fn reads_a_photoshop_preset() { + let mut p = CameraRaw::default(); + let r = load(&mut p, FIXTURE).unwrap(); + assert_eq!(p.tint, 10.0); + assert_eq!(p.exposure, 0.05); + assert_eq!((p.contrast, p.highlights, p.shadows, p.whites), (-5.0, -20.0, 10.0, 35.0)); + assert_eq!((p.texture, p.clarity, p.vibrance, p.saturation), (30.0, 5.0, 20.0, -5.0)); + assert_eq!((p.hsl_hue[3], p.hsl_sat[3]), (25.0, 20.0)); + assert_eq!((p.sharpen_amount, p.noise_luminance, p.grain_amount), (15.0, 5.0, 10.0)); + assert_eq!(p.grade_blending, 50.0); + // The linear tone curve is PhotoCraft's empty one; an element-form setting is read too. + assert!(p.point_curve.is_empty()); + assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]); + assert_eq!(p.dehaze, 12.0); + // Lens vignetting, calibration and geometry have no counterpart. + assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]); + } + + #[test] + fn save_then_load_round_trips() { + let mut p = CameraRaw { + temperature: -12.0, + exposure: 1.25, + contrast: 30.0, + sharpen_radius: 1.4, + vignette_amount: -40.0, + vignette_style: "paintOverlay".into(), + curve_splits: [20.0, 45.0, 80.0], + point_curve: vec![[0.0, 10.0], [128.0, 150.0], [255.0, 240.0]], + ..CameraRaw::default() + }; + p.hsl_lum[5] = -33.0; + p.grade_shadows.hue = 210.0; + p.grade_shadows.sat = 40.0; + p.grade_global.lum = 7.0; + let text = save(&p); + assert!(text.contains("crs:Exposure2012=\"+1.25\"")); + assert!(text.contains("crs:WhiteBalance=\"Custom\"")); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(back, p); + } + + #[test] + fn load_applies_only_the_settings_in_the_file() { + let mut p = CameraRaw { contrast: 40.0, ..CameraRaw::default() }; + let xmp = br#" + -9 + abc"#; + let r = load(&mut p, xmp).unwrap(); + assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + } + + #[test] + fn malformed_input_is_an_error_and_leaves_settings_alone() { + let before = CameraRaw { exposure: 0.5, ..CameraRaw::default() }; + for bad in [ + &b""[..], + b"\xff\xfe\x00garbage", + b"", + b"]>&e;", + ] { + let mut p = before.clone(); + assert!(load(&mut p, bad).is_err()); + assert_eq!(p, before); + } + // A broken curve or NaN is skipped, not fatal. + let xmp = br#" + 10"#; + let mut p = before.clone(); + let r = load(&mut p, xmp).unwrap(); + assert_eq!(p, before); + assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 9852a48516..b6120eb088 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -36,6 +36,7 @@ mod adjust_map; pub mod affinity; pub mod annotations_map; pub mod blocks; +pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index c939714d9c..80ce77f4b2 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -7,7 +7,9 @@ //! one history step (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, -//! "commit":true | "cancel":true}}}`; the reply describes the dialog. +//! "commit":true | "cancel":true}}}`; the reply describes the dialog. `{"ui":{"saveSettings":true}}` +//! and `{"ui":{"loadSettings":true}}` run Save Settings… / Load Settings… (Camera Raw `.xmp` +//! settings files, see [`photocraft_io::camera_raw_xmp`]) through the file dialog. use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::camera_raw::{CameraRaw, Wheel, curve_lut}; @@ -669,14 +671,22 @@ 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" if value.is_boolean() => {} - "before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "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"))), _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) { return Some(Err("Camera Raw cannot commit and cancel together".into())); } + for (key, run) in [("saveSettings", save_settings as fn(&mut PhotocraftApp) -> Result), ("loadSettings", load_settings)] { + if ui.get(key).and_then(Value::as_bool) == Some(true) { + if fields.len() > 1 { + return Some(Err(format!("Camera Raw {key} must be sent on its own"))); + } + return Some(run(app)); + } + } let was_closed = app.camera_raw.is_none(); let previous_view = app.ui.camera_raw_scope.clone(); let previous_navigation = app.ui.camera_raw_preview; @@ -766,6 +776,52 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))) } +/// Save Settings…: the dialog's settings as a Camera Raw `.xmp` settings file. +fn save_settings(app: &mut PhotocraftApp) -> Result { + let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + let bytes = photocraft_io::camera_raw_xmp::save(&d.params).into_bytes(); + app.pick_save("Camera Raw Settings.xmp", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + Ok(json!({"path": path})) + }) +} + +/// Load Settings…: applies a Camera Raw `.xmp` settings file's supported settings over the +/// dialog's; the status bar names how many were not supported. +fn load_settings(app: &mut PhotocraftApp) -> Result { + app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + app.pick_file_bytes_filtered(&["xmp"], |app, name, bytes| { + let file = crate::file_open::display_name(&name); + let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; + let mut next = d.params.clone(); + let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; + if d.params.point_curve != next.point_curve { + d.curve_state = Default::default(); + } + d.dirty |= d.params != next; + 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)]) + } else { + let template = "Loaded Camera Raw settings from {file} ({n} not supported)"; + crate::i18n::fmt(tl!(template), &[("file", &file), ("n", &report.ignored.len().to_string())]) + }; + Ok(json!({"applied": report.applied, "ignored": report.ignored})) + }) +} + +fn report_file_error(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status_error = true; + app.ui.status = e; + } +} + 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 { @@ -1133,6 +1189,13 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { fu.add_space(12.0); widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)")); fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint)); + fu.add_space(12.0); + if fu.button(tl!("Save Settings…")).on_hover_text(tl!("Save these settings as a Camera Raw .xmp file")).clicked() { + action = Some("save"); + } + if fu.button(tl!("Load Settings…")).on_hover_text(tl!("Load settings from a Camera Raw .xmp file")).clicked() { + action = Some("load"); + } }); super::camera_raw_scope_ui::persist(app, ctx); if ctx.input(|i| i.key_pressed(egui::Key::Y)) @@ -1150,6 +1213,14 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } Some("cancel") => cancel(app), + Some("save") => { + let r = save_settings(app); + report_file_error(app, r); + } + Some("load") => { + let r = load_settings(app); + report_file_error(app, r); + } _ => {} } } @@ -1479,4 +1550,68 @@ mod tests { // A non-raw never queues the dialog; a closed document is an error, not a panic. assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); } + + #[test] + fn save_and_load_settings_round_trip_through_xmp_files() { + use crate::file_dialog::FileDialogAnswer; + use std::sync::{Arc, Mutex}; + type Written = Arc)>>>; + let written: Written = Arc::default(); + let w = written.clone(); + let foreign = br#" + + "#; + let answers = vec![ + Some(FileDialogAnswer::SaveTo("/out/Look.xmp".into())), + Some(FileDialogAnswer::Contents("Look.xmp".into(), Vec::new())), + Some(FileDialogAnswer::Contents("Other.xmp".into(), foreign.to_vec())), + Some(FileDialogAnswer::Contents("Broken.xmp".into(), b" Date: Tue, 13 Oct 2026 02:41:23 +0100 Subject: [PATCH 088/223] feat(ui): Photoshop's type formatting keys while editing text (#4239) * feat(engine): relative leading, baseline shift and tracking steps in type.setStyle `leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the range from its own value, so mixed values keep their differences. Auto leading starts from the value layout uses (the paragraph's auto-leading percentage of the character's size), results clamp to Photoshop's ranges, and non-finite or oversized steps are refused as bad params. `leadingBy` follows `metricsAsShown` like `leading`. Co-Authored-By: Claude Opus 5.5 * feat(ui): Photoshop's type formatting keys while editing text While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the selection (off when every selected character has it, else on); Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by 2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100 with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles Hyphenate for the paragraphs touched. Each press is one type.setStyle history step, and the keys no longer fall through to Color Settings, Desaturate, Auto Color or Inverse while typing; outside an edit those shortcuts work as before. A collapsed caret in non-empty text changes nothing: Photoshop styles the next characters typed, which the document model can't hold apart from the text. In empty text the style becomes the typing style. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/type_cmds.rs | 136 ++++++++++++++++- crates/ui-egui/src/input_tests.rs | 245 +++++++++++++++++++++++++++++- crates/ui-egui/src/type_tool.rs | 158 +++++++++++++++++++ docs/ui-parity.md | 4 +- 4 files changed, 538 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index a53b6baba4..4bf193b679 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -133,6 +133,81 @@ fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option std::result::Result<(), String> { + for (key, max) in STEP_KEYS { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(by) if by.is_finite() && by.abs() <= max => {} + _ => return Err(format!("{key} must be a number between -{max} and {max}")), + } + } + } + Ok(()) +} + +/// Relative character steps (Photoshop's ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking +/// over a selection): every character moves from its own value, so mixed values keep their +/// differences. Auto leading starts from the value layout uses, the paragraph's auto-leading +/// percentage of the character's size. Results are clamped to Photoshop's ranges. Returns true +/// if any step key was present. +fn step_char_metrics(t: &mut TextLayer, a: usize, b: usize, p: &Value) -> bool { + let leading_by = f32p(p, "leadingBy").filter(|v| v.is_finite()); + let shift_by = f32p(p, "baselineShiftBy").filter(|v| v.is_finite()); + let tracking_by = f32p(p, "trackingBy").filter(|v| v.is_finite()); + if leading_by.is_none() && shift_by.is_none() && tracking_by.is_none() { + return false; + } + let paragraphs = t.paragraph_runs(); + let default_auto = ParagraphStyle::default().auto_leading; + // Each paragraph's share of `a..b`, with the auto leading it lays out with. + let mut pieces = Vec::new(); + let mut at = 0usize; + for pr in ¶graphs { + let (ps, pe) = (at, at.saturating_add(pr.len)); + at = pe; + let (pa, pb) = (a.clamp(ps, pe), b.clamp(ps, pe)); + if pa < pb { + pieces.push((pa, pb, pr.style.auto_leading)); + } + } + if at < b { + pieces.push((a.max(at), b, default_auto)); + } + if t.text.is_empty() { + // Empty text: the step sets the style the next typed characters get. + pieces = vec![(0, 0, paragraphs.first().map_or(default_auto, |r| r.style.auto_leading))]; + } + for (pa, pb, auto) in pieces { + style_range(t, pa, pb, &|st| { + if let Some(by) = leading_by { + let auto = if auto.is_finite() && auto > 0.0 { auto } else { default_auto }; + let base = st.leading_pt.unwrap_or(auto * st.size_pt); + let base = if base.is_finite() { base } else { 0.0 }; + st.leading_pt = Some((base + by).clamp(LEADING_MIN_PT, LEADING_MAX_PT)); + } + if let Some(by) = shift_by { + let base = if st.baseline_shift_pt.is_finite() { st.baseline_shift_pt } else { 0.0 }; + st.baseline_shift_pt = (base + by).clamp(-BASELINE_SHIFT_MAX_PT, BASELINE_SHIFT_MAX_PT); + } + if let Some(by) = tracking_by { + let base = if st.tracking.is_finite() { st.tracking } else { 0.0 }; + st.tracking = (base + by).clamp(TRACKING_MIN as f32, TRACKING_MAX as f32); + } + }); + } + true +} + /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; @@ -582,7 +657,7 @@ fn shown_style_params<'a>(t: &TextLayer, p: &'a Value) -> Result 1e-3 { f64::from(scale) } else { 1.0 }; let mut params = p.clone(); - for key in ["size", "leading"] { + for key in ["size", "leading", "leadingBy"] { if let Some(value) = p.get(key).and_then(Value::as_f64) { let value = value / scale; if !value.is_finite() || value.abs() > f64::from(f32::MAX) { @@ -868,13 +943,14 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: Box::leak( - format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading include each layer's transform scale), {CHAR_PARAMS}, paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) + format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) .into_boxed_str(), ), enabled: has_doc, journal: true, run: |s, p| { check_kerning(p).map_err(|m| bad("type.setStyle", m))?; + check_steps(p).map_err(|m| bad("type.setStyle", m))?; let ids = with_text_targets(s, p, "Set Type Style", |t, (a, b)| { let props = shown_style_params(t, p)?; check_size_tracking(&props).map_err(|m| bad("type.setStyle", m))?; @@ -884,6 +960,7 @@ pub fn specs() -> Vec { apply_char_props(st, &props); }); } + step_char_metrics(t, a, b, &props); let mut pprobe = ParagraphStyle::default(); if apply_para_props(&mut pprobe, p) { style_paragraphs(t, a, b, &|st| { @@ -1616,4 +1693,59 @@ mod tests { ]) ); } + + /// `leadingBy`, `baselineShiftBy` and `trackingBy` step every character from its own value + /// (auto leading from its laid-out value), clamp to Photoshop's ranges, and reject hostile + /// steps without changing anything. + #[test] + fn relative_steps_move_each_character_from_its_own_value() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello\nworld", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [8, 11], "leading": 30, "tracking": 50})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [6, 11], "autoLeading": 150})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("type.setStyle", json!({"layer": id, "range": [0, 11], "leadingBy": 2, "baselineShiftBy": -2, "trackingBy": 20})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one history step"); + let t = text_layer(&s, id); + let at = |c: usize| { + let byte = byte_at(&t.text, c); + let mut off = 0; + t.char_runs() + .into_iter() + .find(|r| { + off += r.len; + off > byte + }) + .unwrap() + .style + }; + // Auto leading 120% × 10 pt (first paragraph), 150% × 10 pt (second), and a set 30 pt. + assert_eq!(at(0).leading_pt, Some(14.0)); + assert_eq!(at(6).leading_pt, Some(17.0)); + assert_eq!(at(9).leading_pt, Some(32.0)); + assert_eq!((at(0).baseline_shift_pt, at(9).baseline_shift_pt), (-2.0, -2.0)); + assert_eq!((at(0).tracking, at(9).tracking), (20.0, 70.0)); + // Clamped to the ranges. + s.execute("type.setStyle", json!({"layer": id, "leadingBy": -5000, "baselineShiftBy": 10000, "trackingBy": -11000})).unwrap(); + let t = text_layer(&s, id); + assert!(t.char_runs().iter().all(|r| r.style.leading_pt == Some(LEADING_MIN_PT) + && r.style.baseline_shift_pt == BASELINE_SHIFT_MAX_PT + && r.style.tracking == TRACKING_MIN as f32)); + // A collapsed range in non-empty text changes no character. + let before = text_layer(&s, id).char_runs(); + s.execute("type.setStyle", json!({"layer": id, "range": [3, 3], "trackingBy": 100})).unwrap(); + assert_eq!(text_layer(&s, id).char_runs(), before); + // Hostile steps are refused. + let steps = s.active().unwrap().history.entries().len(); + for bad_step in [json!({"leadingBy": "up"}), json!({"trackingBy": 1e30}), json!({"baselineShiftBy": null}), json!({"leadingBy": [1]})] { + let mut p = bad_step.clone(); + p["layer"] = json!(id); + assert!(matches!(s.execute("type.setStyle", p), Err(EngineError::BadParams { .. })), "{bad_step}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + // Empty text: the step sets the style the next typed characters get. + let empty = s.execute("type.create", json!({"x": 0, "y": 80, "text": "", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": empty, "range": [0, 0], "leadingBy": -2})).unwrap(); + assert_eq!(text_layer(&s, empty).char_runs()[0].style.leading_pt, Some(10.0)); + } } diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 956a9de9ba..4ac71a6634 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -504,10 +504,12 @@ fn type_tool_alt_arrows_kern_the_pair() { h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((3, 6))); - // With a selection Alt+→ moves by word instead of kerning; at the text end there's no pair. + // With a selection Alt+→ tracks it instead (see `type_tool_format_keys_*`); at the text end + // there's no pair to kern. let before = kern(&h); - h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); + h.key_press(Key::ArrowRight); h.run_steps(2); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((6, 6))); h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); h.run_steps(2); assert_eq!(kern(&h), before); @@ -518,6 +520,245 @@ fn type_tool_alt_arrows_kern_the_pair() { assert_eq!(kern(&h).1, metric + 120.0); } +/// Starts a type editing session on `layer` with `anchor..caret` selected. +fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer, + caret, + anchor, + session: "format-keys".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); +} + +fn type_layer(h: &Harness<'static, PhotocraftApp>, id: u64) -> photocraft_doc::TextLayer { + match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.clone(), + _ => panic!("not text"), + } +} + +/// The character style of character `c` of a type layer. +fn char_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::CharStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.char_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte || t.text.is_empty() + }) + .unwrap() + .style +} + +/// The paragraph style of the paragraph holding character `c`. +fn para_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::ParagraphStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.paragraph_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte + }) + .unwrap() + .style +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries() +} + +/// Photoshop's ⇧⌘K/U/B/I/: toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough +/// on the selected characters while typing, one step per press, and never reach the global +/// ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse). +#[test] +fn type_tool_format_keys_toggle_character_styles() { + use photocraft_doc::text::{Caps, CharStyle}; + let mut h = harness(); + // A selection, so Select ▸ Inverse would be enabled if the key reached it. + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + type Has = fn(&CharStyle) -> bool; + let keys: [(Key, Has); 6] = [ + (Key::K, |s| s.caps == Caps::AllCaps), + (Key::U, |s| s.underline), + (Key::B, |s| s.faux_bold), + (Key::I, |s| s.faux_italic), + (Key::Slash, |s| s.strikethrough), + (Key::Questionmark, |s| s.strikethrough), + ]; + for (key, has) in keys { + let before = history(&h).len(); + let was = has(&char_style(&h, id, 0)); + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + assert_eq!(has(&char_style(&h, id, 0)), !was, "{key:?} toggles the selection"); + assert_eq!(has(&char_style(&h, id, 4)), !was, "{key:?}"); + assert!(!has(&char_style(&h, id, 6)), "{key:?} leaves the unselected characters"); + let steps = history(&h); + assert_eq!(steps.len(), before + 1, "{key:?}: one history step"); + assert_eq!(steps.last().map(String::as_str), Some("Set Type Style"), "{key:?}"); + assert!(h.state().ui.dialogs.is_empty(), "{key:?} must not open Color Settings"); + assert!(h.state().ui.text_edit.is_some()); + } + // The second strikethrough key turned it off again; press the rest again: all off. + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + let s = char_style(&h, id, 2); + assert!(s.caps == Caps::Normal && !s.underline && !s.faux_bold && !s.faux_italic && !s.strikethrough); + // Partly underlined: the key underlines the whole selection rather than flipping each part. + h.state_mut().run("type.setStyle", json!({"layer": id, "range": [0, 2], "underline": true})).unwrap(); + h.key_press_modifiers(cmd_shift, Key::U); + h.run_steps(2); + assert!((0..5).all(|c| char_style(&h, id, c).underline)); + // Undo takes back one press. + assert!(h.state_mut().session.undo()); + h.run_steps(1); + assert!(char_style(&h, id, 0).underline && !char_style(&h, id, 3).underline); + // A collapsed caret in non-empty text: nothing changes, and nothing global runs. + edit_type(&mut h, id, 3, 3); + let before = history(&h); + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + assert_eq!(history(&h), before); + assert!(h.state().ui.dialogs.is_empty()); + // Empty text: the key sets the style the next characters typed get. + let empty = h.state_mut().run("type.create", json!({"x": 20, "y": 160, "text": "", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, empty, 0, 0); + h.key_press_modifiers(cmd_shift, Key::B); + h.run_steps(2); + h.event(egui::Event::Text("ab".into())); + h.run_steps(2); + assert_eq!(type_layer(&h, empty).text, "ab"); + assert!(char_style(&h, empty, 0).faux_bold && char_style(&h, empty, 1).faux_bold); +} + +/// Outside text editing ⇧⌘K and ⇧⌘I keep their global meanings. +#[test] +fn global_cmd_shift_keys_still_work_outside_type_editing() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + h.key_press_modifiers(cmd_shift, Key::I); + h.run_steps(2); + assert_eq!(history(&h).last().map(String::as_str), Some("Inverse")); + h.key_press_modifiers(cmd_shift, Key::K); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "Color Settings opens"); + assert!(!char_style(&h, id, 0).faux_italic && char_style(&h, id, 0).caps == photocraft_doc::text::Caps::Normal); +} + +/// ⌥↑/↓ leading (2 pt, 10 with ⌘), ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking over a selection +/// (20, 100 with ⌘): one step per press, from each character's own value, the selection kept. +#[test] +fn type_tool_format_keys_step_leading_baseline_and_tracking() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let before = history(&h).len(); + let press = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, k: Key| { + h.key_press_modifiers(m, k); + h.run_steps(2); + }; + // Auto leading is 120% of 10 pt: ⌥↓ makes it 14. + press(&mut h, Modifiers::ALT, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(14.0)); + press(&mut h, Modifiers::ALT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 4).leading_pt, Some(12.0)); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(22.0)); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, 2.0); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, -8.0); + press(&mut h, Modifiers::ALT, Key::ArrowRight); + assert_eq!(char_style(&h, id, 0).tracking, 20.0); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowLeft); + assert_eq!(char_style(&h, id, 4).tracking, -80.0); + assert_eq!(history(&h).len(), before + 7, "one history step per press"); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the selection stays"); + let rest = char_style(&h, id, 7); + assert_eq!((rest.leading_pt, rest.baseline_shift_pt, rest.tracking), (None, 0.0, 0.0)); + // A collapsed caret: ⌥↑/↓ change nothing and don't move the caret. + edit_type(&mut h, id, 8, 8); + let steps = history(&h).len(); + press(&mut h, Modifiers::ALT, Key::ArrowDown); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(history(&h).len(), steps); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((8, 8))); +} + +/// ⌥⇧⌘H toggles Hyphenate for the paragraphs the selection or caret touches. +#[test] +fn type_tool_format_keys_toggle_hyphenate() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"box": [10, 10, 300, 200], "text": "one\ntwo\nthree", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let keys = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; + // The caret in the second paragraph. + edit_type(&mut h, id, 5, 5); + let before = history(&h).len(); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, true, false]); + // A selection over the first two: not all hyphenate, so both do; again, neither. + edit_type(&mut h, id, 1, 5); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [true, true, false]); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, false, false]); + assert_eq!(history(&h).len(), before + 3); +} + +/// The formatting keys never panic: an edit of a layer that has gone, or with no document. +#[test] +fn type_tool_format_keys_survive_hostile_state() { + let mut h = harness(); + edit_type(&mut h, 9999, 0, 3); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::U); + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "an edit of a missing layer ends"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for ed in [None, Some(1)] { + app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { + layer, + caret: 4, + anchor: 0, + session: "x".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + crate::type_tool::toggle_char_style(&mut app, crate::type_tool::CharToggle::AllCaps); + crate::type_tool::step_char_metric(&mut app, "leadingBy", 2.0); + crate::type_tool::step_char_metric(&mut app, "trackingBy", f32::NAN); + crate::type_tool::toggle_hyphenate(&mut app); + } + assert!(app.session.active().is_none()); +} + /// The Character panel's kerning field reads and parses Photoshop's values. #[test] fn kerning_field_values() { diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 291fdde78a..dc99138a32 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -460,6 +460,135 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}})); } +/// A character style the type editor's ⇧⌘ keys toggle (Photoshop: ⇧⌘K All Caps, ⇧⌘U +/// Underline, ⇧⌘B Faux Bold, ⇧⌘I Faux Italic, ⇧⌘/ Strikethrough). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CharToggle { + AllCaps, + Underline, + FauxBold, + FauxItalic, + Strikethrough, +} + +impl CharToggle { + fn is_on(self, st: &photocraft_doc::text::CharStyle) -> bool { + match self { + Self::AllCaps => st.caps == photocraft_doc::text::Caps::AllCaps, + Self::Underline => st.underline, + Self::FauxBold => st.faux_bold, + Self::FauxItalic => st.faux_italic, + Self::Strikethrough => st.strikethrough, + } + } + + fn param(self, on: bool) -> (&'static str, serde_json::Value) { + match self { + Self::AllCaps => ("caps", json!(if on { "all" } else { "normal" })), + Self::Underline => ("underline", json!(on)), + Self::FauxBold => ("fauxBold", json!(on)), + Self::FauxItalic => ("fauxItalic", json!(on)), + Self::Strikethrough => ("strikethrough", json!(on)), + } + } +} + +/// The byte range of characters `a..b` of `text`. +fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { + let at = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + (at(a.min(b)), at(a.max(b))) +} + +/// The character styles of the selection, or of the typing style when the text is empty. Empty +/// when nothing is selected in non-empty text. +fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { + let runs = t.char_runs(); + if t.text.is_empty() { + return runs.into_iter().take(1).map(|r| r.style).collect(); + } + let (ba, bb) = byte_range(&t.text, a, b); + let mut at = 0usize; + let mut out = Vec::new(); + for r in runs { + let (rs, re) = (at, at.saturating_add(r.len)); + at = re; + if rs < bb && re > ba { + out.push(r.style); + } + } + out +} + +/// The character range the type editor's formatting keys act on: the selection, or the empty +/// text's typing style. `None` for a collapsed caret in non-empty text: Photoshop would style the +/// next characters typed, which the document model can't hold apart from the text, so the keys +/// do nothing there (rather than restyle a word the user didn't select). +fn format_range(app: &PhotocraftApp, ed: &TextEdit) -> Option<(usize, usize)> { + let t = text_layer(&app.session.active()?.doc, LayerId(ed.layer))?; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + (a < b || n == 0).then_some((a, b)) +} + +/// One `type.setStyle` step on the edited layer's `range`, kept apart from the typing session's +/// coalesced edit so Undo takes back one key press at a time (as with kerning). +fn set_style(app: &mut PhotocraftApp, ed: &TextEdit, range: (usize, usize), mut p: serde_json::Value) { + crate::type_transform::finish(app); + p["layer"] = json!(ed.layer); + p["range"] = json!([range.0, range.1]); + if let Err(e) = app.run("type.setStyle", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// ⇧⌘K/U/B/I/: flips a character style over the selection: off when every selected character +/// already has it, else on. +pub(crate) fn toggle_char_style(app: &mut PhotocraftApp, which: CharToggle) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + let Some(styles) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))).map(|t| selected_styles(t, range.0, range.1)) else { + return; + }; + let on = !styles.iter().all(|st| which.is_on(st)); + let (key, value) = which.param(on); + set_style(app, &ed, range, json!({ key: value })); +} + +/// ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking: steps every selected character from its +/// own value (`key` is `leadingBy`, `baselineShiftBy` or `trackingBy`). +pub(crate) fn step_char_metric(app: &mut PhotocraftApp, key: &str, by: f32) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + // Leading steps in the points the Character panel shows for a scaled layer. + set_style(app, &ed, range, json!({ key: by, "metricsAsShown": key == "leadingBy" })); +} + +/// ⌥⇧⌘H: flips Hyphenate for the paragraphs the selection (or the caret) is in: off when all of +/// them hyphenate, else on. +pub(crate) fn toggle_hyphenate(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(t) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))) else { return }; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + let (ba, bb) = byte_range(&t.text, a, b); + let len = t.text.len(); + let mut at = 0usize; + let mut all = true; + let mut any = false; + for r in t.paragraph_runs() { + let (ps, pe) = (at, at.saturating_add(r.len)); + at = pe; + let touches = if ba == bb { ps <= ba && (ba < pe || pe >= len) } else { ps < bb && pe > ba }; + if touches { + any = true; + all &= r.style.hyphenate; + } + } + let on = !(any && all); + set_style(app, &ed, (a, b), json!({ "hyphenate": on })); +} + /// IME composition. The preedit text is written into the layer (so it lays out and reflows like /// typed text) and replaced by every update; `commit` makes the result final. fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { @@ -677,6 +806,35 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::Key; let key = &flow_key(*key, vertical_flow); match key { + // Photoshop's type formatting keys act on the selected characters and win + // over the global ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse) + // while typing. Each press is one history step. + Key::K | Key::U | Key::B | Key::I | Key::Slash | Key::Questionmark if m.command && m.shift && !m.alt => { + let which = match key { + Key::K => CharToggle::AllCaps, + Key::U => CharToggle::Underline, + Key::B => CharToggle::FauxBold, + Key::I => CharToggle::FauxItalic, + _ => CharToggle::Strikethrough, + }; + toggle_char_style(app, which); + } + Key::H if m.command && m.shift && m.alt => toggle_hyphenate(app), + // ⌥↑/↓ leading -/+2 pt, ⇧⌥↑/↓ baseline shift +/-2 pt (10 with ⌘/Ctrl). + Key::ArrowUp | Key::ArrowDown if m.alt => { + let step = if m.command { 10.0 } else { 2.0 }; + let up = *key == Key::ArrowUp; + if m.shift { + step_char_metric(app, "baselineShiftBy", if up { step } else { -step }); + } else { + step_char_metric(app, "leadingBy", if up { -step } else { step }); + } + } + // ⌥←/→ over a selection tracks it by -/+20 (100 with ⌘/Ctrl). + Key::ArrowLeft | Key::ArrowRight if m.alt && !m.shift && a != b => { + let step = if m.command { 100.0 } else { 20.0 }; + step_char_metric(app, "trackingBy", if *key == Key::ArrowRight { step } else { -step }); + } Key::Backspace | Key::Delete => { if a == b { let fwd = *key == Key::Delete; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a48db144ae..33fb8b319f 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-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys) · **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, @@ -185,6 +185,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | 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_*`) | | ## Panels and workspace @@ -220,3 +221,4 @@ Paragraph; the rest open as in-app floating windows. | 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 | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | +| 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | From 0002d861eea5b0c91ad41259f6c420aaab3e5112 Mon Sep 17 00:00:00 2001 From: Kartik Dwivedi <116622069+Kartik8Dwivedi@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:49 +0530 Subject: [PATCH 089/223] fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268) Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block alone, with the embedded file in a global lnkD block. Import only read SoLd/SoLE, so such layers got an empty source id and Edit Contents said "the smart object's contents are unavailable". Parse PlLd from the Adobe PSD spec (unique id and the 8 corner doubles). PlLd stores no source size, so the transform takes it from the embedded file's header (linked::source_size_probe: PSD, TIFF, PNG and JPEG headers, any other image decoded flat by photocraft-codecs). It never imports the file as a document: a layered TIFF can carry PSD layer data whose lnkD holds another file, so that would nest without bound. Without the file, 100 % scale is assumed. source_geometry moves to linked.rs. Non-parallelogram corners give the projective placement as for SoLd. An unreadable PlLd is reported as a warning. The PlLd corners are the same placed quad as SoLd's Trnf, so they frame the same source-space box (#3677, blocks::placed_frame): the bounding box of a single-patch custom warp's 16 control points, which may spill past the source, else the source rect. corners_affine now maps such a frame onto the corners and is shared by parse_smart and plld_transform, which is the inverse of smart_map::quad_points (what plld_bytes writes): affine corners frame placed_frame(warp), projective ones the source rect. A custom warp's box needs no source size, so a spilled mesh is placed right even without the embedded file. corners_affine refuses non-finite corners or an empty frame (identity, as before). CS2 can warp a smart object, and PlLd keeps that warp after the corners (u32 warp version, then a versioned warp descriptor). It is read with the same code as the SoLd warp (warp_of), so a re-render from the now available source (Free Transform, Image Size, mode changes) stays warped. A stored warp that can't be read leaves the contents unavailable, keeping the warped pixels, with a warning. An edited PlLd-only layer (moved, transformed, warped, filtered or given other contents) is saved like any other smart object, SoLd plus a regenerated PlLd, with its current placement; an unedited one keeps its record verbatim, as does one whose file isn't embedded (its id is never read as a disk path). plld_edited compares against plld_transform with the record's own warp, so it uses the same frame as import and as the writer. A layer with both SoLE and PlLd is read from SoLE. Tests: PlLd round trip and malformed input (truncation, bad magic, oversized Pascal length, NaN) in blocks.rs; PlLd warps round trip through plld_bytes (preset, custom mesh, identity, none) and unreadable warps are reported as such; a PlLd whose custom mesh spills past the source places like the equivalent SoLd, with and without the source size; PlLd-only layers with a missing file, a broken record, a warp or an unreadable warp; a spilled-mesh PlLd-only layer imports with the SoLd layer's placement and an unedited one saves its PlLd verbatim (no SoLd) and reopens in place; and engine tests that rewrite an export into the CS2 layout (no SoLd, lnkD with version-2 liFD items) and check transform, contents, re-render, Edit Contents, and that a warped smart object re-renders warped. source_size_probe has header tests for each format and malformed JPEG marker walks. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/tests/smart_psd.rs | 147 +++++++++++ crates/io/src/blocks.rs | 284 ++++++++++++++++++++- crates/io/src/linked.rs | 136 +++++++++- crates/io/src/psd_export.rs | 53 ++-- crates/io/src/psd_import.rs | 62 ++++- crates/io/tests/smart_contents_identity.rs | 150 +++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 8 files changed, 803 insertions(+), 39 deletions(-) diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 5f4c7bf6e5..f7b86048b9 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -468,3 +468,150 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } } + +/// A version-2 `liFD` item, as in Photoshop CS2's `lnkD` block: the fields every version has, and +/// none of the version 5–7 fields after the file data. +fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { + let mut item = b"liFD".to_vec(); + item.extend_from_slice(&2u32.to_be_bytes()); + item.push(f.uuid.len() as u8); + item.extend_from_slice(f.uuid.as_bytes()); + let name: Vec = f.file_name.encode_utf16().collect(); + item.extend_from_slice(&(name.len() as u32).to_be_bytes()); + item.extend(name.iter().flat_map(|u| u.to_be_bytes())); + item.extend_from_slice(&photocraft_io::linked::file_type(&f.bytes)); + item.extend_from_slice(b"8BIM"); + item.extend_from_slice(&(f.bytes.len() as u64).to_be_bytes()); + item.push(0); // no open-file descriptor + item.extend_from_slice(&f.bytes); + let mut out = (item.len() as u64).to_be_bytes().to_vec(); + out.extend(item); + out.resize(out.len().next_multiple_of(4), 0); + out +} + +/// `doc` saved as PSD, then rewritten into Photoshop CS2's layout: no `SoLd`, and `lnkD` with +/// version-2 items. +fn cs2_layout(doc: &Document) -> Vec { + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + for rec in file.layers_mut() { + rec.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE")); + } + assert!(file.layers().iter().any(|l| l.block(b"PlLd").is_some())); + let links = file.global_blocks.iter_mut().find(|b| &b.key == b"lnk2").unwrap(); + let files = photocraft_io::linked::parse_linked_files(&links.data); + assert_eq!(files.len(), 1); + links.key = *b"lnkD"; + links.data = files.iter().flat_map(lifd_v2).collect(); + file.to_bytes().unwrap() +} + +/// Photoshop CS2 predates `SoLd`: its placed layers carry a `PlLd` block alone, and the embedded +/// file sits in a global `lnkD` block of older `liFD` items. Such a smart object's contents open +/// (they were "unavailable") and it re-renders in place from them (#2465). +#[test] +fn cs2_plld_only_smart_object_opens_its_contents() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") }; + let nested = decode_source("x.pcraft", pcraft).unwrap(); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + for (a, b) in after.transform.m.iter().zip(before.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", after.transform, before.transform); + } + let (name, src) = photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).expect("the contents are available"); + let inner = decode_source(&name, &src).unwrap(); + assert_eq!((inner.size, inner.layers.len()), (nested.size, nested.layers.len())); + // It re-renders where it was placed. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut rerendered = back.clone(); + *rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + // And Edit Contents (the double-click) opens the nested document. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.select_layer(photocraft_doc::LayerId(bid)).unwrap(); + s2.execute("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(s2.active().unwrap().doc.size, nested.size); +} + +/// CS2 can warp a smart object, and `PlLd` keeps the warp after the corners. Now that a +/// `PlLd`-only layer's contents are available, Free Transform, Image Size and mode changes +/// re-render it from them: it must come back warped, not silently unwarped (#2465). +#[test] +fn cs2_plld_only_warped_smart_object_stays_warped() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "wave", "bend": 60})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + assert!(before.warp.is_some()); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + assert_eq!(after.warp, before.warp); + assert!(photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).is_ok(), "the contents are available"); + // Re-rendered from its source, it is still warped where it was placed. + let rerender = |warp| { + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!() }; + sm.warp = warp; + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut d = back.clone(); + *d.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + max_diff(&photocraft_compose::flatten(&d).px, &photocraft_compose::flatten(&doc).px) + }; + let d = rerender(after.warp.clone()); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + assert!(rerender(None) > 0.1, "an unwarped re-render would differ visibly"); +} + +/// A CS2 `PlLd`-only smart object is saved as it was while nothing changed it, and with its +/// current placement once it is moved: the old record was written back unchanged, so the save +/// reopened at the old place (#2465). +#[test] +fn cs2_plld_only_smart_object_saves_its_new_transform() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let cs2 = cs2_layout(&s.active().unwrap().doc); + let plld = |bytes: &[u8]| { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let rec = f.layers().iter().find(|l| l.block(b"PlLd").is_some()).cloned().expect("a placed layer"); + (rec.block(b"PlLd").unwrap().data.clone(), rec.block(b"SoLd").is_some()) + }; + let back = photocraft_io::import("cs2.psd", &cs2).unwrap().document; + let (bid, _) = smart(&back); + + // Unedited: the CS2 record is written back as it was. + let same = photocraft_io::export(&back, "psd", &Default::default()).unwrap().bytes; + assert_eq!(plld(&same), (plld(&cs2).0, false)); + + // Moved and scaled: written with the new placement, and it reopens there. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.execute("edit.transform", json!({"layer": bid, "matrix": [0.5, 0, 0, 0.5, 20, 12]})).unwrap(); + let edited = s2.active().unwrap().doc.clone(); + let (_, want) = smart(&edited); + let saved = photocraft_io::export(&edited, "psd", &Default::default()).unwrap().bytes; + assert!(structure_errors(&saved).is_empty(), "{:?}", structure_errors(&saved)); + assert_ne!(plld(&saved).0, plld(&cs2).0, "the old PlLd record was written back"); + let again = photocraft_io::import("again.psd", &saved).unwrap().document; + let (aid, got) = smart(&again); + for (a, b) in got.transform.m.iter().zip(want.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", got.transform, want.transform); + } + assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available"); +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 3a6b220d18..87fda102fe 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -527,15 +527,25 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, /// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. +/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { - use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - let w = get_desc(&d, "warp")?; + warp_of(get_desc(&d, "warp")?).flatten() +} + +/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) +/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it +/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +fn warp_of(w: &Descriptor) -> Option> { + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; + if style == WarpStyle::None { + return Some(None); + } let b = get_desc(w, "bounds")?; let g = |k: &str| num(b.get(k)); let bounds = [g("Left")?, g("Top ")?, g("Rght")?, g("Btom")?]; @@ -558,7 +568,7 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option, w: f64, h if ok { b } else { rect } } -/// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). +/// Smart object identifier and transform from `SoLd`/`SoLE` (best effort). A `PlLd` block +/// stores no source size, so its transform needs the embedded file: see [`parse_plld`]. pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { // SoLd: 'soLD' + version(4) + versioned descriptor. let desc = if key == b"SoLd" || key == b"SoLE" { data.get(8..).and_then(parse_prefix_versioned) } else { None }; @@ -611,16 +622,97 @@ pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { if let (Some(Value::List(pts)), Some(sz)) = (d.get("Trnf"), get_desc(&d, "Sz ")) { let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); let (w, h) = (num(sz.get("Wdth")).unwrap_or(0.0), num(sz.get("Hght")).unwrap_or(0.0)); - if p.len() == 8 && w > 0.0 && h > 0.0 { - let [x0, y0, x1, y1] = placed_frame(parse_placed_warp(key, data).as_ref(), w, h); - let (bw, bh) = (x1 - x0, y1 - y0); - let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); - affine = Affine { m: [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0] }; + if let (Ok(p), true) = (<[f64; 8]>::try_from(p), w > 0.0 && h > 0.0) { + affine = corners_affine(&p, placed_frame(parse_placed_warp(key, data).as_ref(), w, h)).unwrap_or(Affine::IDENTITY); } } (id, affine) } +/// The affine that takes the source-space box `frame` (left, top, right, bottom; see +/// [`placed_frame`]) onto placed corners `p` (top-left, top-right, bottom-right, bottom-left as +/// x, y pairs). `None` for an empty or non-finite frame, or non-finite corners. +pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { + let [x0, y0, x1, y1] = frame; + let (bw, bh) = (x1 - x0, y1 - y0); + if !(frame.iter().chain(p).all(|v| v.is_finite()) && bw > 0.0 && bh > 0.0) { + return None; + } + let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); + let m = [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0]; + m.iter().all(|v| v.is_finite()).then_some(Affine { m }) +} + +/// The transform and (Distort/Perspective) projective placement of a `PlLd` record's corners `p` +/// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s +/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's +/// control-point box (which may spill past the source), else the source rect; projective corners +/// frame the source rect, as `parse_smart_perspective` reads them. `PlLd` stores no source size: +/// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being +/// its size (a custom warp's box needs no size); position and rotation stay right, only Replace +/// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record +/// verbatim) both place a `PlLd` layer with this. +pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { + let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); + let perspective = corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (w, h)); + let frame = if perspective.is_some() { [0.0, 0.0, w, h] } else { placed_frame(warp, w, h) }; + (corners_affine(p, frame).unwrap_or(Affine::IDENTITY), perspective) +} + +/// The source identifier and placed corners of a `PlLd` block, the placed-layer record Photoshop +/// CS2 and older write with no `SoLd` next to it (#2465). Adobe PSD spec, "Placed Layer": `plcL`, +/// version, Pascal unique id (unpadded), page, total pages, anti-alias policy, layer type, then +/// 8 doubles: the x, y of each corner (top-left, top-right, bottom-right, bottom-left). The warp +/// that follows is read by [`parse_plld_warp`]. `None` for anything else or a truncated record. +pub fn parse_plld(data: &[u8]) -> Option<(String, [f64; 8])> { + plld_record(data).map(|(id, corners, _)| (id, corners)) +} + +/// [`parse_plld`] and the offset just past the corners, where the warp starts. +fn plld_record(data: &[u8]) -> Option<(String, [f64; 8], usize)> { + if data.get(..4)? != b"plcL" { + return None; + } + let n = usize::from(*data.get(8)?); + let id = String::from_utf8_lossy(data.get(9..9 + n)?).into_owned(); + // Page number, total pages, anti-alias policy and layer type (4 × u32). + let at = 9 + n + 16; + let mut corners = [0.0; 8]; + for (i, c) in corners.iter_mut().enumerate() { + let b = data.get(at + i * 8..at + i * 8 + 8)?; + *c = f64::from_be_bytes(b.try_into().ok()?); + } + corners.iter().all(|v| v.is_finite()).then_some((id, corners, at + 64)) +} + +/// The warp of a placed layer's `PlLd` record (Photoshop CS2 can warp a smart object). +#[derive(Debug, Clone, PartialEq)] +pub enum PlldWarp { + /// No warp: `warpNone`, a warp that moves nothing, or a record that ends at its corners. + None, + /// The warp, in source pixels like the `SoLd` one. + Warp(photocraft_geom::warp::Warp), + /// A warp is stored but can't be read (or the record itself can't be): re-rendering the + /// layer from its source would drop it. + Unreadable, +} + +/// The warp after a `PlLd` record's corners: a u32 warp version, then a versioned `warp` +/// descriptor (Adobe PSD spec, "Placed Layer"; the layout [`crate::smart_map::plld_bytes`] +/// writes). +pub fn parse_plld_warp(data: &[u8]) -> PlldWarp { + let Some(rest) = plld_record(data).and_then(|(_, _, end)| data.get(end..)) else { return PlldWarp::Unreadable }; + // Only padding after the corners: no warp stored. + if rest.iter().all(|b| *b == 0) { + return PlldWarp::None; + } + match rest.get(4..).and_then(parse_prefix_versioned).as_ref().and_then(warp_of) { + Some(Some(w)) => PlldWarp::Warp(w), + Some(None) => PlldWarp::None, + None => PlldWarp::Unreadable, + } +} + /// The projective placement of a `SoLd` / `SoLE` block whose corners aren't a parallelogram /// (Distort, Perspective): source pixels → document pixels, row-major 3×3. Read from /// `nonAffineTransform` (the placed corners), else `Trnf`. `None` for affine placements, which @@ -638,6 +730,12 @@ pub fn parse_smart_perspective(key: &[u8; 4], data: &[u8]) -> Option<[f64; 9]> { (p.len() == 8 && p.iter().all(|v| v.is_finite())).then(|| [[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]]) }; let q = quad("nonAffineTransform").or_else(|| quad("Trnf"))?; + corners_perspective(q, (w, h)) +} + +/// The projective placement of a `w` × `h` source onto corners `q` that aren't a parallelogram; +/// `None` for affine corners (see [`corners_affine`]) or an empty size. +pub fn corners_perspective(q: [[f64; 2]; 4], (w, h): (f64, f64)) -> Option<[f64; 9]> { // A parallelogram (top-left + bottom-right = top-right + bottom-left) is affine. let tol = 1e-6 * (1.0 + q.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()))); if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || w <= 0.0 || h <= 0.0 { @@ -916,6 +1014,172 @@ mod more_tests { assert_eq!(id, "uuid-1"); assert_eq!(a.m, [2.0, 0.0, 0.0, 2.0, 10.0, 20.0]); assert_eq!(parse_smart(b"PlLd", &data).0, ""); + assert_eq!(parse_plld(&data), None, "soLD data is not a plcL record"); + } + + /// A `PlLd` record (as Photoshop CS2 writes it, with no `SoLd`, #2465) gives its source id and + /// placed corners; with the source size those are the same transform `SoLd` gives. + #[test] + fn plld_id_and_corners() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let t = Affine { m: [1.5, 0.25, -0.5, 2.0, 10.0, 20.0] }; + let spec = PlacedSpec { idnt: "uuid-cs2", placed: "", transform: t, perspective: None, size: (40.0, 30.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + let (id, p) = parse_plld(&data).expect("a plcL record"); + assert_eq!(id, "uuid-cs2"); + assert_eq!(p, [10.0, 20.0, 70.0, 30.0, 55.0, 90.0, -5.0, 80.0]); + let back = corners_affine(&p, [0.0, 0.0, 40.0, 30.0]).unwrap(); + for (a, b) in back.m.iter().zip(t.m) { + assert!((a - b).abs() < 1e-12, "{back:?} vs {t:?}"); + } + assert_eq!(corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (40.0, 30.0)), None, "affine corners"); + assert_eq!(corners_affine(&p, [0.0, 0.0, 0.0, 30.0]), None); + assert_eq!(corners_affine(&p, [0.0, 0.0, f64::NAN, 30.0]), None); + assert_eq!(corners_affine(&p, [5.0, 0.0, 5.0, 30.0]), None, "an empty frame"); + let mut nan = p; + nan[3] = f64::INFINITY; + assert_eq!(corners_affine(&nan, [0.0, 0.0, 40.0, 30.0]), None); + // The placed-layer record is the one block that can be the principal one: PlLd-only + // layers have no smart transform from `parse_smart`. + assert_eq!(parse_smart(b"PlLd", &data), (String::new(), Affine::IDENTITY)); + } + + /// A `PlLd` record's corners frame the same box as `SoLd`'s `Trnf` (#3677): with a custom + /// warp whose control points spill past the source, the control-point box, not the source + /// rect. The CS2 record then places the layer exactly as the equivalent `SoLd` does, with or + /// without the source size (the box needs none). + #[test] + fn plld_corners_frame_a_spilled_custom_warp_like_sold() { + use crate::smart_map::{PlacedSpec, parse_sold, plld_bytes, sold_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp}; + let bounds = [0.0, 0.0, 50.0, 25.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-10.0, -5.0]; + mesh.points[15] = [60.0, 25.0]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [2.0, 0.5, -0.25, 2.0, 120.0, 210.0] }; + let spec = PlacedSpec { idnt: "cs2", placed: "p", transform: t, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let plld = plld_bytes(&spec, &mut Vec::new()); + let sold = sold_bytes(None, &spec, &mut Vec::new()); + let (_, p) = parse_plld(&plld).expect("a plcL record"); + assert_eq!(parse_plld_warp(&plld), PlldWarp::Warp(warp.clone())); + let from_sold = parse_smart(b"SoLd", &sold).1; + assert_eq!(parse_sold(&sold).map(|s| s.transform), Some(from_sold)); + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(from_sold, t), "{from_sold:?} vs {t:?}"); + for size in [Some((50.0, 25.0)), None] { + let (got, perspective) = plld_transform(&p, size, Some(&warp)); + assert!(close(got, from_sold), "size {size:?}: {got:?} vs SoLd {from_sold:?}"); + assert_eq!(perspective, None); + } + // Framing the source rect instead would stretch it (the bug #3677 fixed for SoLd). + let rect = corners_affine(&p, [0.0, 0.0, 50.0, 25.0]).unwrap(); + assert!(!close(rect, t), "the test needs a mesh that spills: {rect:?}"); + // With no warp, or a preset one, the frame is the source rect, as before. + let plain = PlacedSpec { warp: None, ..spec }; + let (_, q) = parse_plld(&plld_bytes(&plain, &mut Vec::new())).unwrap(); + assert!(close(plld_transform(&q, Some((50.0, 25.0)), None).0, t)); + } + + /// Malformed `PlLd` data (wrong magic, truncated anywhere, a Pascal length past the end, + /// non-finite corners) reads as `None`, never a panic. + #[test] + fn plld_malformed_is_none() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + // Magic + version + Pascal "id" + 4 × u32 + 8 × f64: everything after is the warp. + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(parse_plld(&data[..end]).is_some()); + for n in 0..end { + assert_eq!(parse_plld(&data[..n]), None, "truncated to {n} bytes"); + } + let mut bad = data.clone(); + bad[..4].copy_from_slice(b"soLD"); + assert_eq!(parse_plld(&bad), None); + let mut long = data.clone(); + long[8] = 255; // a Pascal id longer than the record + assert_eq!(parse_plld(&long[..200.min(long.len())]), None); + let mut nan = data.clone(); + nan[end - 8..end].copy_from_slice(&f64::NAN.to_be_bytes()); + assert_eq!(parse_plld(&nan), None); + assert_eq!(parse_plld(&[]), None); + } + + /// Photoshop CS2 can warp a smart object; `PlLd` keeps that warp after the corners (a u32 + /// warp version, then the versioned `warp` descriptor). It reads back as written, and + /// `warpNone`, an identity warp or a record that stops at its corners mean no warp. + #[test] + fn plld_warps_round_trip() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; + let bounds = [0.0, 0.0, 60.0, 30.0]; + let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds); + arc.vertical = true; + arc.h_distort = 10.0; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[15] = [67.0, 30.0]; + let custom = Warp::custom(mesh, bounds); + let flat = Warp::preset(WarpStyle::Flag, 0.0, bounds); + for (w, back) in + [(Some(&arc), PlldWarp::Warp(arc.clone())), (Some(&custom), PlldWarp::Warp(custom.clone())), (Some(&flat), PlldWarp::None), (None, PlldWarp::None)] + { + let t = Affine { m: [0.5, 0.0, 0.0, 0.5, 3.0, 4.0] }; + let spec = PlacedSpec { idnt: "w", placed: "", transform: t, perspective: None, size: (60.0, 30.0), dpi: 72.0, warp: w, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data), back, "{w:?}"); + // The corners are unchanged by the warp that follows them. + assert_eq!(parse_plld(&data).map(|(_, p)| p[..2].to_vec()), Some(vec![3.0, 4.0])); + } + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let end = 4 + 4 + 1 + 2 + 16 + 64; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data[..end]), PlldWarp::None, "no warp stored"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0; 3]].concat()), PlldWarp::None, "padding"); + } + + /// A `PlLd` warp that is stored but can't be read (truncated, an unknown style, a missing + /// bounds rectangle or mesh) is `Unreadable`, so the import keeps the warped pixels. + #[test] + fn plld_unreadable_warps() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{Warp, WarpStyle}; + let arc = Warp::preset(WarpStyle::Arc, 40.0, [0.0, 0.0, 4.0, 4.0]); + let spec = PlacedSpec { + idnt: "id", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (4.0, 4.0), + dpi: 72.0, + warp: Some(&arc), + filter_fx: None, + }; + let data = plld_bytes(&spec, &mut Vec::new()); + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(matches!(parse_plld_warp(&data), PlldWarp::Warp(_))); + for n in end + 1..data.len() - 4 { + if data[end..n].iter().any(|b| *b != 0) { + assert_eq!(parse_plld_warp(&data[..n]), PlldWarp::Unreadable, "truncated to {n} bytes"); + } + } + let with = |d: Descriptor| [data[..end].to_vec(), vec![0; 4], VersionedDescriptor::new(d).to_bytes()].concat(); + let style = |s: &str| Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new(s) }; + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpSomethingNew")))), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpArc")))), PlldWarp::Unreadable, "no bounds"); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpNone")))), PlldWarp::None); + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(4.0)) + .with("Rght", Value::Double(4.0)); + let custom = Descriptor::new("warp").with("warpStyle", style("warpCustom")).with("bounds", Value::Descriptor(bounds)); + assert_eq!(parse_plld_warp(&with(custom)), PlldWarp::Unreadable, "custom with no mesh"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0, 0, 0, 0, 0, 0, 0, 16, 1, 2]].concat()), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&data[..end - 1]), PlldWarp::Unreadable, "the record itself"); + assert_eq!(parse_plld_warp(&[]), PlldWarp::Unreadable); } /// `SoLd` data with a 50 × 25 source, the quad `trnf` and, when given, a custom warp whose diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index b1b7286d25..636b6a1266 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -84,14 +84,101 @@ pub fn parse_linked_files(data: &[u8]) -> Vec { /// The embedded file for smart-object `uuid` among the document's preserved PSD global blocks. pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { + find_in_blocks(meta.psd_global_blocks.iter().map(|(_, k, d)| (k, d.as_slice())), uuid) +} + +/// The embedded file for smart-object `uuid` among PSD global blocks given as (key, data). +pub fn find_in_blocks<'a>(blocks: impl IntoIterator, uuid: &str) -> Option { if uuid.is_empty() { return None; } - meta.psd_global_blocks - .iter() - .filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")) - .flat_map(|(_, _, d)| parse_linked_files(d)) - .find(|f| f.uuid == uuid) + blocks.into_iter().filter(|(k, _)| matches!(*k, b"lnk2" | b"lnk3" | b"lnkD")).flat_map(|(_, d)| parse_linked_files(d)).find(|f| f.uuid == uuid) +} + +/// The size of embedded file `uuid` among PSD global blocks given as (key, data), when it is there +/// and readable with a non-empty size (the source size a `PlLd` record leaves out). +pub(crate) fn source_size_in_blocks<'a>( + blocks: impl IntoIterator, + uuid: &str, + ctl: &photocraft_raster::Interrupt, +) -> Option<(f64, f64)> { + let f = find_in_blocks(blocks, uuid)?; + if ctl.cancelled() { + return None; + } + source_size_probe(&f.bytes).filter(|(w, h)| *w > 0.0 && *h > 0.0) +} + +/// 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). +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)); + match bytes { + _ if crate::is_psd(bytes) => be32(18).zip(be32(14)).map(|(w, h)| size(w, h)), + [b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] | [b'I', b'I', 43, 0, ..] | [b'M', b'M', 0, 43, ..] => { + let info = photocraft_codecs::tiff_info(bytes).ok()?; + let page = info.pages.get(info.default_page()?)?; + Some(size(page.width, page.height)) + } + [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) + }), + } +} + +/// A JPEG's size from its first start-of-frame marker (ITU-T T.81 B.2.2), walking the marker +/// segments without decoding. Each step moves forward, so a malformed file ends the walk. +fn jpeg_size(bytes: &[u8]) -> Option<(f64, f64)> { + let mut at = 2usize; + loop { + // Markers are 0xFF then a code; extra 0xFF fill bytes may come first. + while bytes.get(at) == Some(&0xFF) && bytes.get(at + 1) == Some(&0xFF) { + at += 1; + } + if *bytes.get(at)? != 0xFF { + return None; + } + let code = *bytes.get(at + 1)?; + match code { + // SOF0..SOF15, except DHT (C4), JPG (C8) and DAC (CC). + 0xC0..=0xCF if !matches!(code, 0xC4 | 0xC8 | 0xCC) => { + let h = bytes.get(at + 5..at + 7)?; + let w = bytes.get(at + 7..at + 9)?; + return Some((f64::from(u16::from_be_bytes([w[0], w[1]])), f64::from(u16::from_be_bytes([h[0], h[1]])))); + } + // Standalone markers have no length. + 0x01 | 0xD0..=0xD7 => at = at.checked_add(2)?, + // Start of scan or end of image before any frame header. + 0xDA | 0xD9 => return None, + _ => { + let len = bytes.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]])))?; + if len < 2 { + return None; + } + at = at.checked_add(2 + len)?; + } + } + } +} + +/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). +/// Other formats are decoded in full, which `ctl` can cancel (its progress is left alone: this is +/// a step of a larger operation). +pub(crate) fn source_geometry(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<((f64, f64), f64), String> { + if crate::is_psd(bytes) { + let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); + let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; + return Ok(((w, h), 72.0)); + } + let cancel = || ctl.cancelled(); + let doc = crate::import_with(name, bytes, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|e| e.to_string())?.document; + Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) } /// The four-character file type Photoshop records for an embedded file, from its contents. @@ -210,6 +297,45 @@ mod tests { use super::*; use std::sync::Arc; + /// #2465: a placed source's size comes from its header, for every format a CS2 file embeds, + /// and never by importing it as a document. + #[test] + 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] { + let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); + assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + } + let mut psd = b"8BPS\0\x01".to_vec(); + psd.extend([0; 6]); + psd.extend(3u16.to_be_bytes()); + psd.extend(40u32.to_be_bytes()); // height + psd.extend(30u32.to_be_bytes()); // width + assert_eq!(source_size_probe(&psd), Some((30.0, 40.0))); + assert_eq!(source_size_probe(b"not an image"), None); + assert_eq!(source_size_probe(&[]), None); + } + + /// Malformed JPEG marker walks end without a size and without panicking or looping. + #[test] + fn jpeg_size_rejects_malformed_marker_walks() { + let sof = [0xFF, 0xC0, 0, 17, 8, 0, 5, 0, 7]; + let ok: Vec = [&[0xFF, 0xD8][..], &[0xFF, 0xE0, 0, 4, 1, 2], &[0xFF, 0xFF, 0xD0], &sof].concat(); + assert_eq!(jpeg_size(&ok), Some((7.0, 5.0)), "APP0, a fill byte and a standalone marker are skipped"); + for bad in [ + vec![0xFF, 0xD8], + vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 1], // length below 2 + vec![0xFF, 0xD8, 0xFF, 0xE0, 0xFF, 0xFF], // length past the end + vec![0xFF, 0xD8, 0x00, 0x00], // not a marker + vec![0xFF, 0xD8, 0xFF, 0xDA, 0, 2], // scan before any frame header + vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8], // truncated frame header + [&[0xFF, 0xD8][..], &[0xFF; 64]].concat(), + ] { + assert_eq!(jpeg_size(&bad), None, "{bad:?}"); + } + } + #[test] fn round_trips_and_finds_by_uuid() { let a = LinkedFile { uuid: "abc-1".into(), file_name: "a.png".into(), bytes: vec![1, 2, 3] }; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index a17e783366..822364bb85 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -244,17 +244,6 @@ impl SmartOut { } } -/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). -fn source_geometry(name: &str, bytes: &[u8]) -> Result<((f64, f64), f64), String> { - if crate::is_psd(bytes) { - let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); - let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; - return Ok(((w, h), 72.0)); - } - let doc = crate::import(name, bytes).map_err(|e| e.to_string())?.document; - Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) -} - /// The source size of a smart object whose file we can't decode: its rendered bounds taken back /// through the inverse transform (the source's top-left is the origin). fn size_from_layer(sm: &photocraft_doc::SmartObject) -> (f64, f64) { @@ -593,11 +582,14 @@ impl Ex { } LayerContent::Smart(sm) => { let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d)); - if sm.psd_raw.is_some() && parsed.is_none() { - // Placed-layer data we don't parse (`PlLd` only): kept verbatim. + if sm.psd_raw.is_some() && parsed.is_none() && !self.plld_edited(sm) { + // Placed-layer data that isn't `SoLd` (a CS2 `PlLd` record, or one we can't + // read) and still says what the layer is: kept verbatim. self.smart.prune_ok = false; set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref()); } else { + // An edited CS2 `PlLd`-only layer is written like any other smart object + // (`SoLd` and a regenerated `PlLd`), with its current placement (#2465). self.smart_blocks(l, sm, parsed, &mut raw); } } @@ -767,6 +759,36 @@ impl Ex { self.warnings.extend(warnings.into_iter().map(|w| if w.starts_with("layer ") { w } else { format!("layer \"{}\": {w}", l.name) })); } + /// Whether a CS2 placed layer (`PlLd` with no `SoLd`, #2465) no longer matches its record: + /// moved, transformed, warped, filtered or given other contents. `false` for a record that + /// can't be read (or whose warp can't), or whose file isn't embedded: its contents were never + /// available, so it is kept. + fn plld_edited(&self, sm: &photocraft_doc::SmartObject) -> bool { + let Some(data) = sm.psd_raw.as_deref() else { return false }; + let Some((id, corners)) = blocks::parse_plld(data) else { return false }; + // Without its embedded file the contents were never available: keep the record as it + // is, rather than resolve the id (bytes from the file) as a path on disk. + if !self.smart.known.contains(&id) { + return false; + } + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::Unreadable => return false, + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + }; + if sm.source != (photocraft_doc::SmartSource::Linked { path: id.clone() }) + || sm.warp != warp + || !sm.smart_filters.is_empty() + || sm.filter_mask.is_some() + { + return true; + } + // Placed as the importer places it (the same source size, from the same file, and the + // same frame: a custom warp's control-point box, #3677). + let size = crate::linked::source_size_in_blocks(self.smart.globals.iter().map(|(_, k, d)| (k, d.as_slice())), &id, &photocraft_raster::Interrupt::NONE); + blocks::plld_transform(&corners, size, warp.as_ref()) != (sm.transform, sm.perspective) + } + /// Whether an imported placed layer still says what the smart object is: transform, warp, /// filter stack and filter mask as stored. fn placed_unchanged(&self, t: &crate::smart_map::Placed, data: &[u8], sm: &photocraft_doc::SmartObject, stack: &crate::smart_map::FilterStack) -> bool { @@ -798,7 +820,7 @@ impl Ex { let geometry = stored.map(|size| (size, f64::from(self.dpi))).or_else(|| { let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() }; let f = crate::linked::find_linked_file(&meta, path)?; - source_geometry(&f.file_name, &f.bytes).ok() + crate::linked::source_geometry(&f.file_name, &f.bytes, &photocraft_raster::Interrupt::NONE).ok() }); let (size, dpi) = geometry.unwrap_or((size_from_layer(sm), f64::from(self.dpi))); let original = self.smart.linked_contents.entry(path.clone()).or_insert(sm.contents_id); @@ -908,7 +930,8 @@ impl Ex { (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) } else { // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. - let (size, dpi) = source_geometry(file_name, bytes).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + let (size, dpi) = + crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); (file_name.to_string(), bytes.to_vec(), size, dpi, None) }; let uuid = contents_uuid(contents_id, &data); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index af19a0d76e..1e77cdaa6a 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -59,6 +59,8 @@ pub(crate) struct Ctx<'a> { pub filter_effects: Vec, /// Placed layers naming the same source share editable contents, not their placement. pub smart_contents: std::collections::HashMap, + /// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file). + pub plld_sizes: std::collections::HashMap>, /// Cancellation and progress for background opens (checked per layer record). pub ctl: photocraft_raster::Interrupt<'a>, /// Layer records decoded so far, out of `total` (progress). @@ -105,11 +107,51 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 (soft || pattern).then_some((fill, soft)) } +/// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. +/// An empty `id` leaves the contents unavailable (the stored pixels stay the rendering). +#[derive(Default)] +struct Placement { + id: String, + transform: photocraft_geom::Affine, + perspective: Option<[f64; 9]>, + warp: Option, +} + impl Ctx<'_> { fn warn(&mut self, s: impl Into) { self.warnings.push(s.into()); } + /// Source id, transform, (Distort/Perspective) projective placement and warp of a placed + /// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd` + /// stores the placed corners but not the source size, which comes from the embedded file. + fn plld_placement(&mut self, data: &[u8], name: &str) -> Placement { + let Some((id, p)) = blocks::parse_plld(data) else { + self.warn(format!("layer \"{name}\": its placed-layer data (PlLd) could not be read; the smart object's contents are unavailable")); + return Placement::default(); + }; + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + blocks::PlldWarp::Unreadable => { + // Re-rendering from the source would unwarp the layer: keep it as saved (its + // warped pixels), as before PlLd was read. + self.warn(format!( + "layer \"{name}\": its smart object warp (PlLd) could not be read; the layer keeps its warped pixels but its contents are unavailable" + )); + return Placement::default(); + } + }; + let (file, ctl) = (self.file, self.ctl); + let size = *self + .plld_sizes + .entry(id.clone()) + .or_insert_with(|| crate::linked::source_size_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id, &ctl)); + // Without the source (missing or unreadable), it is taken as placed at 100 %. + let (transform, perspective) = blocks::plld_transform(&p, size, warp.as_ref()); + Placement { id, transform, perspective, warp } + } + fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { rec.channel(id)?; match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { @@ -267,7 +309,8 @@ impl Ctx<'_> { let fill_key = [b"SoCo", b"GdFl", b"PtFl"].into_iter().find(|k| rec.block(k).is_some()); let vector_key = [b"vsms", b"vmsk"].into_iter().find(|k| rec.block(k).is_some()); let adj_key = ADJUSTMENT_KEYS.into_iter().find(|k| rec.block(k).is_some()); - let smart_key = [b"SoLd", b"PlLd", b"SoLE"].into_iter().find(|k| rec.block(k).is_some()); + // `SoLE` (a linked smart object) before `PlLd`: a `PlLd` alone is Photoshop CS2's record. + let smart_key = [b"SoLd", b"SoLE", b"PlLd"].into_iter().find(|k| rec.block(k).is_some()); let blocks = preserved_blocks(rec); // `psd_raw` shares the Arc of the matching `psd_blocks` entry. let principal = |k: &[u8; 4]| blocks.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.clone()); @@ -321,7 +364,16 @@ impl Ctx<'_> { t.psd_raw = principal(b"TySh"); LayerContent::Text(t) } else if let Some(k) = smart_key { - let (id, transform) = rec.block(k).map(|b| blocks::parse_smart(k, &b.data)).unwrap_or_default(); + let Placement { id, transform, perspective, warp } = match rec.block(k) { + Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name), + Some(b) => { + let (id, transform) = blocks::parse_smart(k, &b.data); + // Distort / Perspective: the fourth corner (the affine `transform` drops it). + let perspective = blocks::parse_smart_perspective(k, &b.data); + Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) } + } + None => Placement::default(), + }; let contents_id = if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) }; // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). @@ -340,10 +392,9 @@ impl Ctx<'_> { psd_raw: principal(k), filters_enabled: stack.enabled, filter_mask, - warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)), + warp, stack_mode: None, - // Distort / Perspective: the fourth corner (the affine `transform` drops it). - perspective: rec.block(k).and_then(|b| blocks::parse_smart_perspective(k, &b.data)), + perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { // A shape whose vector mask has a density or feather is a fill layer seen through a @@ -650,6 +701,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), filter_effects: Vec::new(), smart_contents: Default::default(), + plld_sizes: Default::default(), ctl: *ctl, done: 0, total: file.layers().len(), diff --git a/crates/io/tests/smart_contents_identity.rs b/crates/io/tests/smart_contents_identity.rs index 632547b0d4..5a0714b3d8 100644 --- a/crates/io/tests/smart_contents_identity.rs +++ b/crates/io/tests/smart_contents_identity.rs @@ -199,3 +199,153 @@ fn independent_unavailable_link_retains_separate_preserved_record() { let (again, _) = round_trip(&doc, "psd"); assert_eq!(after.to_bytes().unwrap(), again.to_bytes().unwrap()); } + +/// A `PlLd`-only placed layer (Photoshop CS2, #2465) whose embedded file is missing still gets its +/// source id and placement (at an assumed 100 % scale); an unreadable `PlLd` is reported. +#[test] +fn plld_only_layers_without_their_file_or_with_a_broken_record() { + let (mut file, _) = round_trip(&fixture(), "psd"); + file.global_blocks.retain(|b| !matches!(&b.key, b"lnk2" | b"lnk3" | b"lnkD")); + let rotated = Affine { m: [0.0, 1.0, -1.0, 0.0, 6.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: "missing", placed: "", transform: rotated, perspective: None, size: (3.0, 2.0), dpi: 72.0, warp: None, filter_fx: None }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let mut broken = record.clone(); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + broken.blocks.push(TaggedBlock::new(*b"PlLd", b"plcL\0\0\0\x03\x05ab\0\0".to_vec())); + *file.layers_mut() = vec![record, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(a) = &doc.layers[0].content else { panic!("PlLd layer is not a smart object") }; + assert_eq!(a.source, SmartSource::Linked { path: "missing".into() }); + for (x, y) in a.transform.m.iter().zip(rotated.m) { + assert!((x - y).abs() < 1e-12, "{:?} vs {rotated:?}", a.transform); + } + let LayerContent::Smart(b) = &doc.layers[1].content else { panic!("broken PlLd layer is not a smart object") }; + assert_eq!(b.source, SmartSource::Linked { path: String::new() }); + assert_eq!(warnings.iter().filter(|w| w.contains("(PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// Photoshop CS2 can warp a smart object and keeps the warp in `PlLd`, after the corners. A +/// `PlLd`-only layer imports with that warp, so re-rendering it from its now-available source +/// (Free Transform, Image Size, mode changes) keeps it warped. A warp that can't be read leaves +/// the contents unavailable, so the warped pixels stay the rendering (#2465). +#[test] +fn plld_only_layers_keep_their_warp() { + use photocraft_geom::warp::{Warp, WarpStyle}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let bulge = Warp::preset(WarpStyle::Bulge, 35.0, [0.0, 0.0, 2.0, 2.0]); + let spec = |warp| smart_map::PlacedSpec { + idnt: "missing", + placed: "", + transform: Affine { m: [2.0, 0.0, 0.0, 2.0, 1.0, 0.0] }, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut warped, mut flat, mut broken) = (record.clone(), record.clone(), record); + warped.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()))); + flat.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(None), &mut Vec::new()))); + // The warp descriptor cut short: a warp is stored but can't be read. + let mut cut = smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()); + cut.truncate(cut.len() - 40); + broken.blocks.push(TaggedBlock::new(*b"PlLd", cut)); + *file.layers_mut() = vec![warped, flat, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let sm = |i: usize| match &doc.layers[i].content { + LayerContent::Smart(s) => s, + _ => panic!("layer {i} is not a smart object"), + }; + assert_eq!(sm(0).warp, Some(bulge)); + assert_eq!(sm(0).source, SmartSource::Linked { path: "missing".into() }); + assert_eq!((sm(1).warp.as_ref(), &sm(1).source), (None, &SmartSource::Linked { path: "missing".into() })); + assert_eq!(sm(2).source, SmartSource::Linked { path: String::new() }, "unreadable warp: contents unavailable"); + assert!(sm(2).cache.is_some()); + assert_eq!(warnings.iter().filter(|w| w.contains("warp (PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// A linked smart object with both `SoLE` and `PlLd` is read from `SoLE`: `PlLd` alone is +/// Photoshop CS2's record (#2465). +#[test] +fn sole_wins_over_plld() { + let (mut file, _) = round_trip(&fixture(), "psd"); + let spec = smart_map::PlacedSpec { + idnt: "plld-only", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp: None, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + let sold = record.block(b"SoLd").expect("the fixture has SoLd").data.clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &sold); + assert!(!id.is_empty() && id != "plld-only"); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + record.blocks.push(TaggedBlock::new(*b"SoLE", sold.clone())); + *file.layers_mut() = vec![record]; + let (doc, _) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("not a smart object") }; + assert_eq!(sm.source, SmartSource::Linked { path: id }); + assert_eq!(sm.psd_raw.as_deref(), Some(&sold)); +} + +/// A CS2 `PlLd`-only layer with a custom warp whose control points spill past its source frames +/// that control-point box with its corners, as `SoLd`'s `Trnf` does (#3677): it imports with the +/// same placement as the equivalent `SoLd` layer. Unedited, it saves its `PlLd` record verbatim +/// (no `SoLd` added) and reopens in the same place (#2465). +#[test] +fn plld_only_spilled_custom_warp_places_like_sold_and_round_trips_verbatim() { + use photocraft_geom::warp::{BezierMesh, Warp}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let mut record = file.layers()[0].clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &record.block(b"SoLd").expect("the fixture has SoLd").data); + assert!(!id.is_empty()); + // The embedded source is 2 × 2; the mesh reaches from (-1, -0.5) to (3, 2.5). + let bounds = [0.0, 0.0, 2.0, 2.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-1.0, -0.5]; + mesh.points[15] = [3.0, 2.5]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [1.5, 0.0, 0.0, 1.5, 4.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: &id, placed: "p", transform: t, perspective: None, size: (2.0, 2.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut with_sold, mut cs2) = (record.clone(), record); + with_sold.blocks.push(TaggedBlock::new(*b"SoLd", smart_map::sold_bytes(None, &spec, &mut Vec::new()))); + let plld = smart_map::plld_bytes(&spec, &mut Vec::new()); + cs2.blocks.push(TaggedBlock::new(*b"PlLd", plld.clone())); + *file.layers_mut() = vec![with_sold, cs2]; + + let (doc, warnings) = photocraft_io::psd_to_document(&file); + assert!(!warnings.iter().any(|w| w.contains("PlLd")), "{warnings:?}"); + let sm = |d: &Document, i: usize| match &d.layers[i].content { + LayerContent::Smart(s) => s.clone(), + _ => panic!("layer {i} is not a smart object"), + }; + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + let (a, b) = (sm(&doc, 0), sm(&doc, 1)); + assert!(close(a.transform, t), "SoLd: {:?} vs {t:?}", a.transform); + assert!(close(b.transform, a.transform), "PlLd {:?} vs SoLd {:?}", b.transform, a.transform); + assert_eq!((&b.source, &b.warp, b.perspective), (&a.source, &a.warp, a.perspective)); + assert_eq!(b.warp.as_ref(), Some(&warp)); + assert_eq!(b.source, SmartSource::Linked { path: id.clone() }); + + // Unedited: the CS2 record is written back as it was, with no SoLd, and reopens in place. + let out = export(&doc, "psd", &ExportOptions::default()).unwrap(); + let saved = PsdFile::from_bytes(&out.bytes).unwrap(); + let rec = &saved.layers()[1]; + assert_eq!(rec.block(b"PlLd").map(|b| &b.data), Some(&plld)); + assert!(rec.block(b"SoLd").is_none(), "an unedited PlLd-only layer gained a SoLd"); + let again = import("psd", &out.bytes).unwrap().document; + let c = sm(&again, 1); + assert!(close(c.transform, t), "{:?} vs {t:?}", c.transform); + assert_eq!(c.warp.as_ref(), Some(&warp)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 12c6dc9d1d..39f7ece88f 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-12 · **Change:** minor (DPX read and write, #1049; 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-12 · **Change:** minor (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), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469, #2465 (#2374, text layers, closed 2026-10-10) | +| 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 133/256, 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) | | 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) | @@ -141,6 +141,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | diff --git a/docs/gaps.md b/docs/gaps.md index 5a50685368..8e75167810 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-12 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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 @@ -46,7 +46,7 @@ what an agent can't finish alone. oracles 133/256 (52%), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not - opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465), PSB smart object + opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object blank (#1764), perspective placed layers flattened (#579), PSB > 4 GB fails on Windows (#375). No test opens our exports in a third-party editor (FILE-216-4). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. @@ -240,6 +240,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | From 72ef526d8e0a78c1f842bf74c0ef07f3e9877196 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:47:04 +0200 Subject: [PATCH 090/223] Pen: show a close cue when hovering the first anchor (#4193) (#4361) Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 3a5dade0e2..a13f8fa569 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -304,6 +304,12 @@ pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Would a press at `at` close the Pen path: is the pointer on the first anchor of a path that +/// already has a segment, and no drag is under way? The canvas then shows the close cue (#4193). +pub(crate) fn over_first_anchor(pen: &PenPath, at: [f64; 2], tol: f64) -> bool { + !pen.dragging && pen.moving.is_none() && pen.knots.len() >= 2 && pen.knots.first().is_some_and(|k| (k[0][0] - at[0]).hypot(k[0][1] - at[1]) < tol) +} + /// Is a close-drag in progress: does the path end in the stand-in for its first anchor? fn closing(pen: &PenPath) -> bool { pen.knots.len() >= 3 && pen.knots.first().map(|k| k[0]) == pen.knots.last().map(|k| k[0]) @@ -892,6 +898,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } draw_anchor(painter, c, last, accent); } + // Hovering the first anchor: Photoshop's close cue, a small ring beside the pointer. + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + if let Some(h) = app.hover_doc + && over_first_anchor(pen, h, tol) + && let Some(first) = pen.knots.first() + { + let c = to_scr(first[0]); + painter.rect_filled(Rect::from_center_size(c, vec2(8.0, 8.0)), 0.0, accent); + painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); + } } } @@ -1881,6 +1897,21 @@ mod tests { assert!(photocraft_engine::vector_cmds::shape_path(&json!({"kind": "rect"})).is_err()); } + #[test] + 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_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_up(&mut app); + let two = app.ui.pen.clone().unwrap(); + assert!(over_first_anchor(&two, [22.0, 21.0], 6.0)); + assert!(!over_first_anchor(&two, [60.0, 60.0], 6.0)); + } + #[test] fn pen_builds_and_closes_a_work_path() { let mut app = app(); From f1855caf73ea3bc32a4ebe90d08807771b886e18 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:47:09 +0200 Subject: [PATCH 091/223] Type tool: spelling suggestions in the edit menu (#4370) (#4375) * Type tool: spelling suggestions in the edit menu (#4370) Co-Authored-By: Claude Opus 5.5 * Type spelling menu: cap suggestions at 3 so the menu stays short (#4370) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas_tool_menu.rs | 157 ++++++++++++++++++++- crates/ui-egui/src/control.rs | 23 ++- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/text_field_menu.rs | 6 +- crates/ui-egui/src/transform_type_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 5 + docs/context-menu-parity.md | 4 +- docs/scorecard.md | 2 +- scorecard/ui.toml | 2 +- 28 files changed, 223 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3094ce6fa1..0277fbdf43 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3183,7 +3183,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p)); } } else { - crate::canvas_tool_menu::open(app, &ctx, tool, [p.x, p.y]); + crate::canvas_tool_menu::open_at(app, &ctx, tool, [p.x, p.y], Some(xf.to_doc(p))); } } // The (temporary) Hand pans above; its gestures never reach the tool underneath. While a diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index b6b86111e1..7fe4fce9e3 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -31,8 +31,30 @@ 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, + /// The misspelled word under the pointer when the type edit menu opened: its suggestions head + /// the menu. + #[serde(default)] + pub spelling: Option, } +/// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spelling { + pub layer: u64, + /// The word's characters `start..end` in the layer's text. + pub start: usize, + pub end: usize, + pub word: String, + pub suggestions: Vec, +} + +/// Most suggestions the type edit menu lists. +const MAX_SUGGESTIONS: usize = 3; +/// The spelling rows' ids: `spelling.replace.` picks the n-th suggestion. +const SPELLING_REPLACE: &str = "spelling.replace."; +const SPELLING_NONE: &str = "spelling.none"; +const SPELLING_ADD: &str = "spelling.add"; + /// One menu row: label and command id. `None` is a separator. pub type Row = Option<(&'static str, &'static str)>; @@ -275,7 +297,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - rows(menu).iter().flatten().any(|(_, id)| *id == command) && entry_enabled(app, menu, command) + (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command)) && entry_enabled(app, menu, command) } /// Is `command` the transform box's current mode (checked in the transform menu)? @@ -311,6 +333,49 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The misspelled word of the edited text under document point `doc`, with its suggestions. +fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { + let layer = app.ui.text_edit.as_ref()?.layer; + if !doc.iter().all(|v| v.is_finite()) { + return None; + } + let at = crate::type_tool::offset_at(app, photocraft_doc::LayerId(layer), doc[0], doc[1]); + let found = app.session.execute("edit.checkSpelling", json!({"action": "list", "layer": layer, "suggestions": MAX_SUGGESTIONS})).ok()?; + let hit = found["misspellings"].as_array()?.iter().find(|m| { + let (a, b) = (m["start"].as_u64().unwrap_or(u64::MAX) as usize, m["end"].as_u64().unwrap_or(0) as usize); + a <= at && at <= b + })?; + Some(Spelling { + layer, + start: hit["start"].as_u64()? as usize, + end: hit["end"].as_u64()? as usize, + word: hit["word"].as_str()?.to_string(), + suggestions: hit["suggestions"].as_array()?.iter().filter_map(|s| s.as_str().map(str::to_string)).take(MAX_SUGGESTIONS).collect(), + }) +} + +/// The spelling rows heading the type edit menu (label, id): the suggestions, or a greyed note +/// when there are none, then Add to Dictionary. Empty away from a misspelled word. +pub fn spelling_rows(menu: &CanvasToolMenu) -> Vec<(String, String)> { + let Some(sp) = menu.spelling.as_ref().filter(|_| menu.text) else { return Vec::new() }; + let mut rows: Vec<(String, String)> = sp.suggestions.iter().enumerate().map(|(i, s)| (s.clone(), format!("{SPELLING_REPLACE}{i}"))).collect(); + if rows.is_empty() { + rows.push(("No Spelling Suggestions".into(), SPELLING_NONE.into())); + } + rows.push(("Add to Dictionary".into(), SPELLING_ADD.into())); + rows +} + +/// Is `command` one of the menu's spelling rows? +fn is_spelling(menu: &CanvasToolMenu, command: &str) -> bool { + spelling_rows(menu).iter().any(|(_, id)| id == command) +} + +/// Does a spelling row's label come from the UI (translated) rather than from the dictionary? +pub fn spelling_label_is_ui(id: &str) -> bool { + matches!(id, SPELLING_NONE | SPELLING_ADD) +} + /// Right-click over a Free Transform box: its modes. pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> bool { if !pos.iter().all(|v| v.is_finite()) { @@ -328,6 +393,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -346,10 +412,24 @@ pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), _ if menu.text && matches!(command, FAUX_BOLD | FAUX_ITALIC) => ("type.setStyle".into(), json!({})), + _ if is_spelling(menu, command) => spelling_call(menu, command), _ => (command.into(), json!({})), } } +/// The `edit.checkSpelling` call a spelling row makes: replace the word with a suggestion, or add +/// it to the user dictionary. +fn spelling_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { + let Some(sp) = menu.spelling.as_ref() else { return ("edit.checkSpelling".into(), json!({})) }; + let replace = command.strip_prefix(SPELLING_REPLACE).and_then(|n| n.parse::().ok()).and_then(|n| sp.suggestions.get(n)); + match replace { + Some(to) => { + ("edit.checkSpelling".into(), json!({"action": "change", "layer": sp.layer, "start": sp.start, "end": sp.end, "word": sp.word, "replace": to})) + } + None => ("edit.checkSpelling".into(), json!({"action": "addToDictionary", "word": sp.word})), + } +} + /// Does a right-click with `tool` at document point `doc` open the pasteboard menu? Outside the /// image, for the tools whose right-click has no job there: not the painting tools (their Brush /// Preset picker or right-button erase), the Pen (its path menu) or text being edited. @@ -377,6 +457,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -406,6 +487,8 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } if menu.text { return match command { + SPELLING_NONE => false, + c if is_spelling(menu, c) => app.ui.text_edit.is_some(), FAUX_BOLD | FAUX_ITALIC => app.ui.text_edit.is_some(), c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; @@ -449,6 +532,12 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -> bool { + open_at(app, ctx, tool, pos, None) +} + +/// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being +/// edited, the menu starts with its spelling suggestions. +pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { return false; @@ -458,7 +547,9 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some()); let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text }); + 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 }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -481,6 +572,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -521,6 +613,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_pasteboard(app, &menu, command) } else if menu.anchor.is_some() { choose_view(app, ctx, &menu, command) + } else if menu.text && is_spelling(&menu, command) { + choose_spelling(app, &menu, command) } else if menu.text { choose_type(app, ctx, command) } else if menu.tool == Tool::Pen && !menu.transform { @@ -538,6 +632,19 @@ 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 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() + { + (e.caret, e.anchor) = (end, end); + } + Ok(()) +} + /// A type edit row. Undo, Cut, Copy, Paste and Select All run as their Edit and Select menu items, /// which act on the text while it is being edited (Paste asks the window for the clipboard text). fn choose_type(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> { @@ -668,6 +775,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { ui.spacing_mut().item_spacing.y = 0.0; ui.set_width(200.0); crate::widgets::menu_scroll(ui, |ui| { + let spelling = spelling_rows(&menu); + for (label, command) in &spelling { + let text = if spelling_label_is_ui(command) { tl!(label).to_string() } else { label.clone() }; + let item = egui::Button::new(text).min_size(egui::vec2(200.0, 20.0)); + if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { + selected = Some(command.clone()); + } + } + if !spelling.is_empty() { + ui.separator(); + } for row in rows(&menu) { let Some((label, command)) = *row else { ui.separator(); @@ -675,14 +793,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { }; let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(label)).min_size(egui::vec2(200.0, 20.0)); if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { - selected = Some(command); + selected = Some((*command).to_string()); } } }); }); }); if let Some(command) = selected { - choose(app, ctx, command); + choose(app, ctx, &command); } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) { app.ui.canvas_tool_menu = None; } @@ -849,6 +967,37 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + #[test] + fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { + let mut app = app(); + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Teh cat", "size": 12, "x": 4, "y": 20})).unwrap()["layer"].as_u64().unwrap(); + let ctx = Context::default(); + crate::menus::invoke(&mut app, &ctx, "type.editText", json!({})).unwrap(); + // Away from the misspelling (past the end of the line): no spelling rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + assert!(spelling_rows(app.ui.canvas_tool_menu.as_ref().unwrap()).is_empty()); + // Over "Teh": its suggestions head the menu, then Add to Dictionary. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([5.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + let rows = spelling_rows(&menu); + assert!(rows.iter().any(|(l, _)| l == "The"), "{rows:?}"); + assert_eq!(rows.last().map(|(_, id)| id.as_str()), Some(SPELLING_ADD)); + assert!(rows.len() <= MAX_SUGGESTIONS + 1, "at most {MAX_SUGGESTIONS} suggestions and Add to Dictionary: {rows:?}"); + assert!(available(&app, &menu, &rows[0].1)); + let pick = rows.iter().find(|(l, _)| l == "The").unwrap().1.clone(); + choose(&mut app, &ctx, &pick); + assert_eq!(text_of(&app, id).unwrap().text, "The cat"); + assert_eq!(app.ui.text_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((3, 3)), "the caret lands after the replacement"); + // Add to Dictionary stops flagging a word. + app.run("type.insertText", json!({"layer": id, "text": " Zqx"})).unwrap(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.spelling.as_ref().is_some_and(|s| s.word == "Zqx")); + choose(&mut app, &ctx, SPELLING_ADD); + assert!(app.session.prefs().user_dictionary.iter().any(|w| w == "Zqx")); + } + #[test] fn right_click_outside_the_image_picks_the_pasteboard_color() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ceacb71928..e797b27cc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -366,6 +366,22 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest outcome } +/// The open canvas context menu's rows for `ui.state`: the spelling suggestions first (type edit +/// menu over a misspelled word), then the menu's own rows. +fn context_entries(app: &PhotocraftApp, menu: &crate::canvas_tool_menu::CanvasToolMenu) -> Vec { + use crate::canvas_tool_menu::{entry_enabled, rows, spelling_rows}; + let spelling = spelling_rows(menu); + let mut entries: Vec = spelling.iter().map(|(label, id)| json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)})).collect(); + if !spelling.is_empty() { + entries.push(json!({"separator": true})); + } + entries.extend(rows(menu).iter().map(|row| match row { + Some((label, id)) => json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)}), + None => json!({"separator": true}), + })); + entries +} + fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -1027,7 +1043,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { if down { - crate::canvas_tool_menu::open(app, ctx, app.ui.tool, screen_point(app, x, y)); + crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } continue; } @@ -1259,10 +1275,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "pos": menu.pos, "tool": menu.tool, "pasteboard": menu.pasteboard, - "entries": crate::canvas_tool_menu::rows(menu).iter().map(|row| match row { - Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}), - None => json!({"separator": true}), - }).collect::>() + "entries": context_entries(app, menu) }) }), "panels": app.ui.panels, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6b2c38cc89..2a9c4a9d5d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -138,6 +138,8 @@ Search… Hledat… Check Spelling… Kontrola pravopisu… Find and Replace Text… Najít a nahradit text… + Add to Dictionary Přidat do slovníku + No Spelling Suggestions Žádné návrhy pravopisu Fill… Vyplnit… Stroke… Tah… Content-Aware Fill… Výplň s ohledem na obsah… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e86037b7f4..d209af709e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -134,6 +134,8 @@ Search… Suchen… Check Spelling… Rechtschreibung prüfen… Find and Replace Text… Text suchen und ersetzen… + Add to Dictionary Zum Wörterbuch hinzufügen + No Spelling Suggestions Keine Rechtschreibvorschläge Fill… Füllen… Stroke… Nachziehen… Content-Aware Fill… Inhaltsbasiert füllen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ffa683b6e0..e27766fefa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -115,6 +115,8 @@ Search… Αναζήτηση… Check Spelling… Ορθογραφικός έλεγχος… Find and Replace Text… Εύρεση και αντικατάσταση κειμένου… + Add to Dictionary Προσθήκη στο λεξικό + No Spelling Suggestions Δεν υπάρχουν ορθογραφικές προτάσεις Fill… Γέμισμα… Stroke… Περίγραμμα… Preferences… Προτιμήσεις… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 49b56d3520..62886211e8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -131,6 +131,8 @@ Search… Buscar… Check Spelling… Revisar ortografía… Find and Replace Text… Buscar y reemplazar texto… + Add to Dictionary Añadir al diccionario + No Spelling Suggestions Sin sugerencias ortográficas Fill… Rellenar… Stroke… Contornear… Content-Aware Fill… Relleno según el contenido… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1e30b6a39e..7789b2c02b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -136,6 +136,8 @@ Search… Rechercher… Check Spelling… Vérifier l'orthographe… Find and Replace Text… Rechercher et remplacer du texte… + Add to Dictionary Ajouter au dictionnaire + No Spelling Suggestions Aucune suggestion orthographique Fill… Remplir… Stroke… Contourner… Content-Aware Fill… Remplissage d'après le contenu… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3a74e745f..1c9f15f0ba 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -126,6 +126,8 @@ File Info… Fájladatok… Fill… Kitöltés… Find and Replace Text… Szöveg keresése és cseréje… + Add to Dictionary Hozzáadás a szótárhoz + No Spelling Suggestions Nincs helyesírási javaslat Fit Image… Kép méretre igazítása… Flatten All Layer Effects Összes rétegeffektus egybesimítása Flatten All Masks Összes maszk egybesimítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 30fecce4c2..cb77490c4b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -129,6 +129,8 @@ Search… Cari… Check Spelling… Periksa Ejaan… Find and Replace Text… Cari dan Ganti Teks… + Add to Dictionary Tambahkan ke Kamus + No Spelling Suggestions Tidak Ada Saran Ejaan Fill… Isi… Stroke… Stroke… Content-Aware Fill… Isi Content-Aware… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 12a8c283ea..288353a61b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -131,6 +131,8 @@ Search… Cerca… Check Spelling… Controlla ortografia… Find and Replace Text… Trova e sostituisci testo… + Add to Dictionary Aggiungi al dizionario + No Spelling Suggestions Nessun suggerimento ortografico Fill… Riempi… Stroke… Traccia… Content-Aware Fill… Riempimento in base al contenuto… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fafb869f8e..26a31ce4de 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -132,6 +132,8 @@ Search… 検索… Check Spelling… スペルチェック… Find and Replace Text… テキストを検索して置換… + Add to Dictionary 辞書に追加 + No Spelling Suggestions スペルの候補はありません Fill… 塗りつぶし… Stroke… 境界線を描く… Content-Aware Fill… コンテンツに応じた塗りつぶし… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3609ad270d..b6a95907d3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -121,6 +121,8 @@ Search… 검색… Check Spelling… 맞춤법 검사… Find and Replace Text… 텍스트 찾기 및 바꾸기… + Add to Dictionary 사전에 추가 + No Spelling Suggestions 맞춤법 제안 없음 Fill… 채우기… Stroke… 획… Content-Aware Fill… 내용 인식 채우기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index abc3f3f5c6..1ec6d64b0f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -124,6 +124,8 @@ Search… Zoeken… Check Spelling… Spelling controleren… Find and Replace Text… Tekst zoeken en vervangen… + Add to Dictionary Aan woordenboek toevoegen + No Spelling Suggestions Geen spellingsuggesties Fill… Vullen… Stroke… Omlijnen… Content-Aware Fill… Inhoudsbewust vullen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ddda4d88d4..fab352b28a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -131,6 +131,8 @@ Search… Szukaj… Check Spelling… Sprawdź pisownię… Find and Replace Text… Znajdź i zamień tekst… + Add to Dictionary Dodaj do słownika + No Spelling Suggestions Brak sugestii pisowni Fill… Wypełnij… Stroke… Obrys… Content-Aware Fill… Wypełnienie z uwzględnieniem zawartości… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 05408b834e..b7c0def324 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -132,6 +132,8 @@ Search… Pesquisar… Check Spelling… Verificar ortografia… Find and Replace Text… Localizar e substituir texto… + Add to Dictionary Adicionar ao dicionário + No Spelling Suggestions Sem sugestões de ortografia Fill… Preencher… Stroke… Traçado… Content-Aware Fill… Preenchimento sensível ao conteúdo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6e5ee708ae..2f174fd7b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -270,6 +270,8 @@ Check Spelling Проверка орфографии Language Options Языковые параметры Find and Replace Text… Найти и заменить текст… + Add to Dictionary Добавить в словарь + No Spelling Suggestions Нет вариантов написания Paste Lorem Ipsum Вставить условный текст Glyphs Panel Панель «Глифы» Type Styles Стили текста diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4305ff7faa..e9dc17d8d4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -121,6 +121,8 @@ Search… Ara… Check Spelling… Yazımı Denetle… Find and Replace Text… Metni Bul ve Değiştir… + Add to Dictionary Sözlüğe Ekle + No Spelling Suggestions Yazım Önerisi Yok Fill… Doldur… Stroke… Kontur… Content-Aware Fill… İçeriğe Uygun Dolgu… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index de4e42fc58..0facb9e9cc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -248,6 +248,8 @@ Check Spelling Перевірити правопис Language Options Мовні параметри Find and Replace Text… Знайти та замінити текст… + Add to Dictionary Додати до словника + No Spelling Suggestions Немає варіантів написання Paste Lorem Ipsum Вставити Lorem Ipsum Glyphs Panel Панель гліфів Type Styles Текстові стилі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5ed7ec483a..03ad654f76 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -73,6 +73,8 @@ Fill… Tô… Filter Bộ lọc Find and Replace Text… Tìm và thay chữ… + Add to Dictionary Thêm vào từ điển + No Spelling Suggestions Không có gợi ý chính tả Fit Image… Vừa khung ảnh… Flatten All Layer Effects Làm phẳng mọi hiệu ứng lớp Flatten All Masks Làm phẳng mọi mặt nạ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 29d2b57ea6..080a5ac03d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -130,6 +130,8 @@ Search… 搜索… Check Spelling… 检查拼写… Find and Replace Text… 查找和替换文本… + Add to Dictionary 添加到词典 + No Spelling Suggestions 没有拼写建议 Fill… 填充… Stroke… 描边… Content-Aware Fill… 内容识别填充… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 36f523934d..3ed91649c2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -138,6 +138,8 @@ Search… 搜尋… Check Spelling… 拼字檢查… Find and Replace Text… 尋找並取代文字… + Add to Dictionary 加入字典 + No Spelling Suggestions 沒有拼字建議 Fill… 填滿… Stroke… 筆畫… Content-Aware Fill… 內容感知填滿… diff --git a/crates/ui-egui/src/text_field_menu.rs b/crates/ui-egui/src/text_field_menu.rs index 420263e7ef..6556c68cfc 100644 --- a/crates/ui-egui/src/text_field_menu.rs +++ b/crates/ui-egui/src/text_field_menu.rs @@ -17,9 +17,9 @@ //! caret to the pointer on any press), so Copy and Cut act on it. //! //! The right-click belongs to the field: the canvas and its tool menus only see pointer presses -//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu -//! yet: the spell checker (`edit.checkSpelling`, whose `suggest` action lists them) is not wired -//! into text fields. +//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu: +//! the spell checker (`edit.checkSpelling`) checks type layers, so they head the Type tool's edit +//! menu instead (`canvas_tool_menu`, over a misspelled word of the text being edited, #4370). use egui::text::CCursorRange; use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, SurrenderFocusOn, Ui}; diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index ea3c128a18..dcbbb70b3c 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, + spelling: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index dc99138a32..6a7c816084 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -151,6 +151,11 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { } fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { + offset_at(app, id, x, y) +} + +/// The character offset of the caret position nearest document point (`x`, `y`) in layer `id`. +pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); photocraft_text::hit_char(&l, &text, tx, ty).0 diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 425c91f42f..739e445cdc 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Spelling suggestions for the word under the pointer and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | @@ -83,7 +83,7 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | | Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All, Reveal in Finder, and drag to reorder implemented; duplicate/move to window where supported remains open | **Inferred** exact list; P1 | | Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | -| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions missing: `edit.checkSpelling` (`suggest`) is not wired into the menu yet (UI-217-8) | P1; **inferred** exact spelling list | +| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions live in the Type tool's edit menu (canvas_tool_menu.rs), not in generic fields, which hold no type layer text (UI-217-8) | P1; **inferred** exact spelling list | | Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label | | Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | diff --git a/docs/scorecard.md b/docs/scorecard.md index 651d68309f..d25198c082 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -189,7 +189,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | 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; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet | +| 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-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 3fa79b59fa..54cc13876c 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "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; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet" +note = "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" [[item]] id = "UI-217-9" From b507e04f1f78872e66c1dc98e35580dd65aac2ac Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:14 +0200 Subject: [PATCH 092/223] Menus keep clear of a panel that covers a maximized window (X11) (#3737) (#4380) On X11 a window manager that doesn't reserve a bottom panel's space maximizes PhotoCraft to the whole monitor, and the panel covers its bottom. The work-area check (#315) trusted every maximized window to be inside the work area, so long menus (Layer, View, Window, Edit at larger UI scales) ran under the panel with their scroll arrow hidden. A maximized window whose position is known is now checked like any other; only full screen, or maximized with no position (Wayland, #3739), uses the whole window. --- crates/ui-egui/src/menu_nav_tests.rs | 54 ++++++++++++++++- crates/ui-egui/src/work_area.rs | 87 +++++++++++++++++++++------- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 2dafc1c5c1..1643e2765b 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -563,9 +563,61 @@ fn submenus_stay_above_the_taskbar() { } } +/// #3737 on X11 (Linux Mint, 1920 × 1080 px): the window manager maximized the window to the whole +/// monitor without keeping it clear of the bottom panel, so the window's position is known but the +/// panel covers its bottom. At 1× and at a 150% UI scale (1280 × 720 pt). +fn maximized_under_a_panel(scale: f32) -> Harness<'static, PhotocraftApp> { + let size = egui::vec2(1920.0, 1080.0) / scale; + let mut h = harness((1920.0, 1080.0, scale)); + let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + v.monitor_size = Some(size); + v.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), size)); + v.maximized = Some(true); + h.run_steps(3); + h +} + +#[test] +fn menus_of_a_maximized_window_stay_above_a_panel_it_runs_under() { + let mut scrolled = 0; + for scale in [1.0_f32, 1.5] { + for menu in LONGEST.iter().copied().chain(["View", "Window"]) { + let mut h = maximized_under_a_panel(scale); + let bottom = taskbar_bottom(&h); + let what = format!("{scale}x {menu}"); + assert!((bottom - (1080.0 / scale - crate::work_area::TASKBAR)).abs() < 1.0, "{what}: visible bottom {bottom}"); + open(&mut h, menu); + for r in popups(&h) { + assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})"); + } + let last = rows(&h, 0).len() - 1; + let in_view = |h: &Harness<'static, PhotocraftApp>| { + let nav = Nav::current(&h.ctx); + nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5 + }; + let Some(down) = h.query_by_label("Scroll menu down").map(|n| n.rect()) else { + assert!(in_view(&h), "{what}: fits, so its last row is in view"); + continue; + }; + scrolled += 1; + assert!(down.bottom() <= bottom, "{what}: ▼ at {down:?} is under the panel"); + h.hover_at(down.center()); + for _ in 0..400 { + h.run_steps(1); + if in_view(&h) { + break; + } + } + assert!(in_view(&h), "{what}: ▼ scrolls to the last row, above the panel"); + } + } + assert!(scrolled >= 3, "at 150% the long menus overflow above the panel: {scrolled}"); +} + #[test] fn a_maximized_window_or_an_unknown_monitor_uses_the_whole_window() { - let mut h = taskbar_harness((1366.0, 728.0), 0.0, 1.0); + // Maximized inside the work area (the window manager kept it clear of the taskbar). + let mut h = taskbar_harness((1366.0, 720.0), 0.0, 1.0); h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); h.run_steps(2); assert_eq!(crate::work_area::visible_rect(&h.ctx), h.ctx.content_rect()); diff --git a/crates/ui-egui/src/work_area.rs b/crates/ui-egui/src/work_area.rs index 0ac79ea6a2..df4bc7a77e 100644 --- a/crates/ui-egui/src/work_area.rs +++ b/crates/ui-egui/src/work_area.rs @@ -19,11 +19,19 @@ pub const TASKBAR: f32 = 48.0; const MIN_VISIBLE: f32 = 160.0; /// Where the window's content can be seen, in egui points. `monitor` is the monitor's size, -/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen` -/// is true for a maximized or full-screen window (already inside the work area). -pub fn visible_part(content: Rect, monitor: Option, inner: Option, fills_screen: bool) -> Rect { +/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]). `state` says +/// whether the window is maximized or full screen. +pub fn visible_part(content: Rect, monitor: Option, inner: Option, state: WindowState) -> Rect { let Some(m) = monitor else { return content }; - if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + if state == WindowState::FullScreen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + return content; + } + // A maximized window is usually inside the work area already, but not always (#3737): on X11 + // a window manager that doesn't reserve a panel's space (Cinnamon's Muffin with PhotoCraft's + // own title bar, a panel without a strut) maximizes it to the whole monitor, and the panel then + // covers its bottom. Where the window is known, it is checked like any other; only a maximized + // window with no position (Wayland) is taken to fit. + if state == WindowState::Maximized && inner.is_none() { return content; } // #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has @@ -49,6 +57,27 @@ pub fn visible_part(content: Rect, monitor: Option, inner: Option, f trusted(content, local) } +/// How the window sits on its monitor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowState { + Normal, + Maximized, + /// Full screen covers the taskbar on purpose: all of it can be seen. + FullScreen, +} + +impl WindowState { + fn of(v: &egui::ViewportInfo) -> Self { + if v.fullscreen == Some(true) { + Self::FullScreen + } else if v.maximized == Some(true) { + Self::Maximized + } else { + Self::Normal + } + } +} + /// `content` cut to `usable`, unless that leaves too little to be a sensible guess. fn trusted(content: Rect, usable: Rect) -> Rect { let visible = content.intersect(usable); @@ -63,8 +92,7 @@ pub fn visible_rect(ctx: &egui::Context) -> Rect { let content = ctx.content_rect(); ctx.input(|i| { let v = i.viewport(); - let fills = v.maximized == Some(true) || v.fullscreen == Some(true); - visible_part(content, v.monitor_size, v.inner_rect, fills) + visible_part(content, v.monitor_size, v.inner_rect, WindowState::of(v)) }) } @@ -107,6 +135,7 @@ pub fn fit_window(ctx: &egui::Context) -> bool { #[cfg(test)] mod tests { use super::*; + use WindowState::{FullScreen, Maximized, Normal}; use egui::{Rect, pos2, vec2}; const MONITOR: Vec2 = vec2(1366.0, 768.0); @@ -119,29 +148,46 @@ mod tests { fn a_window_taller_than_its_display_is_cut_at_the_taskbar() { // 1440 × 900 at the desktop origin on 1366 × 768: right and bottom are off-screen. let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // Below a 31 pt title bar, a full-height window reaches under the taskbar. let (content, inner) = window(0.0, 31.0, 1366.0, 737.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR - 31.0); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR - 31.0); // A small window clear of the taskbar is all visible. let (content, inner) = window(100.0, 80.0, 800.0, 500.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); // On a second monitor to the right, the same. let (content, inner) = window(1366.0 + 100.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR); } #[test] fn maximized_unknown_or_nonsense_input_keeps_the_window() { let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area"); - assert_eq!(visible_part(content, None, Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), FullScreen), content, "full screen covers the taskbar on purpose"); + assert_eq!(visible_part(content, None, Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), Normal), content); // A guess that would leave almost nothing visible is not trusted. let (content, inner) = window(0.0, 600.0, 1000.0, 300.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); + } + + #[test] + fn a_maximized_window_under_a_panel_is_still_cut_at_the_taskbar() { + // #3737, X11 (Linux Mint): the window manager maximized the window to the whole 1920 × 1080 + // monitor without reserving the bottom panel's space, so the panel covers it. + for scale in [1.0_f32, 1.5] { + let monitor = vec2(1920.0, 1080.0) / scale; + let content = Rect::from_min_size(pos2(0.0, 0.0), monitor); + let bottom = monitor.y - TASKBAR; + assert_eq!(visible_part(content, Some(monitor), Some(content), Maximized).bottom(), bottom, "at {scale}×"); + // A window manager that reserves the panel: the maximized window already ends above it. + let fitted = Rect::from_min_size(pos2(0.0, 0.0), vec2(monitor.x, bottom)); + assert_eq!(visible_part(fitted, Some(monitor), Some(fitted), Maximized), fitted, "at {scale}×"); + // Full screen is meant to cover the panel. + assert_eq!(visible_part(content, Some(monitor), Some(content), FullScreen), content, "at {scale}×"); + } } #[test] @@ -149,13 +195,14 @@ mod tests { // #3737, Wayland: only the monitor's size is known. A window taller than the monitor less // the taskbar can be seen at most down to there, wherever it is. let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0)); - assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0)); - assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(big, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // One that fits, or a maximized one, is all visible. let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)); - assert_eq!(visible_part(small, Some(MONITOR), None, false), small); - assert_eq!(visible_part(content, Some(MONITOR), None, true), content); + assert_eq!(visible_part(small, Some(MONITOR), None, Normal), small); + assert_eq!(visible_part(content, Some(MONITOR), None, Maximized), content); + assert_eq!(visible_part(content, Some(MONITOR), None, FullScreen), content); } fn frame(ctx: &egui::Context, monitor: Option, outer: Option, maximized: Option) -> Vec { From 10ba166a8268557b9feb11fb03bcaa568cd7afc9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:16 +0200 Subject: [PATCH 093/223] feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255) A read-only DirectDraw Surface codec on the model of PCX: the top mip level of a 2D texture, uncompressed (channel masks, RGB or luminance, 8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10 header. Cubemaps, arrays, volumes and other formats are refused with an Unsupported error naming what was found; every header field is treated as hostile (checked sizes, limits before allocating, no indexing on file offsets). Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 4 +- crates/codecs/README.md | 7 + crates/codecs/src/codecs/dds.rs | 284 ++++++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 16 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/dds.rs | 268 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 4 +- 9 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 crates/codecs/src/codecs/dds.rs create mode 100644 crates/codecs/tests/dds.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index daf4ed9efb..e44a0e259b 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", - "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", + "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b24c2a025a..7ec91954b1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 8cba2b1f37..2409c46e07 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -40,6 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | +| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | | QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | @@ -67,6 +68,12 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded ## Documented asymmetries and limitations +* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks + (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), + with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are + ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. + * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`. Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs new file mode 100644 index 0000000000..efe93c7f2e --- /dev/null +++ b/crates/codecs/src/codecs/dds.rs @@ -0,0 +1,284 @@ +//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed +//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed +//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps, +//! texture arrays and volume textures are refused rather than flattened to one face or slice. + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +/// `DDS ` plus the 124-byte DDS_HEADER. +const HEADER: usize = 128; +/// DDS_HEADER_DXT10, present when the FourCC is `DX10`. +const DX10_HEADER: usize = 20; + +const DDSD_DEPTH: u32 = 0x80_0000; +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSCAPS2_CUBEMAP: u32 = 0x200; +const DDSCAPS2_VOLUME: u32 = 0x20_0000; +const DIMENSION_TEXTURE2D: u32 = 3; +const MISC_TEXTURECUBE: u32 = 0x4; + +fn bad(reason: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Dds, reason) +} +fn unsupported(reason: impl Into) -> CodecError { + CodecError::unsupported(Format::Dds, reason) +} +fn le32(b: &[u8], at: usize) -> Result { + let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?; + Ok(u32::from_le_bytes(bytes)) +} + +/// A FourCC as text when it is printable, else as the number legacy writers store there. +fn fourcc_name(fourcc: u32) -> String { + let bytes = fourcc.to_le_bytes(); + if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() } +} + +/// One channel of an uncompressed pixel: where it sits and its largest value. +#[derive(Clone, Copy)] +struct Field { + shift: u32, + max: u32, +} + +impl Field { + /// `None` for an empty mask; an error for one with gaps or wider than the pixel. + fn new(mask: u32, bits: u32) -> Result, CodecError> { + if mask == 0 { + return Ok(None); + } + let shift = mask.trailing_zeros(); + let max = mask >> shift; + if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) { + return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel"))); + } + Ok(Some(Field { shift, max })) + } + + fn get(self, pixel: u32) -> u8 { + let v = u64::from((pixel >> self.shift) & self.max); + let max = u64::from(self.max); + ((v * 255 + max / 2) / max) as u8 + } +} + +enum Encoding { + /// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha. + Masks { + bytes: usize, + gray: bool, + fields: [Option; 4], + }, + Bc1, + Bc2, + Bc3, +} + +impl Encoding { + fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result { + if !matches!(bits, 8 | 16 | 24 | 32) { + return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32"))); + } + let mut fields = [None; 4]; + for (field, mask) in fields.iter_mut().zip(masks) { + *field = Field::new(mask, bits)?; + } + let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) }; + if !colour { + return Err(bad("the pixel format has no colour channel masks")); + } + Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields }) + } + + fn layout(&self) -> ChannelLayout { + match self { + Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA, + Encoding::Masks { gray: true, .. } => ChannelLayout::Gray, + Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } + } + + /// Bytes of the top mip level. + fn level_size(&self, width: u32, height: u32) -> Option { + let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); + let block = match self { + Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), + Encoding::Bc1 => 8, + Encoding::Bc2 | Encoding::Bc3 => 16, + }; + w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + } +} + +/// The legacy pixel format: a FourCC, or channel masks. +fn legacy_encoding(b: &[u8]) -> Result { + let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?); + let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 }; + if flags & DDPF_FOURCC != 0 { + return match &fourcc.to_le_bytes() { + b"DXT1" => Ok(Encoding::Bc1), + b"DXT3" => Ok(Encoding::Bc2), + b"DXT5" => Ok(Encoding::Bc3), + b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")), + _ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))), + }; + } + if flags & DDPF_RGB != 0 { + return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false); + } + if flags & DDPF_LUMINANCE != 0 { + return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true); + } + Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression"))) +} + +/// The DX10 header's DXGI format, refusing anything but a single 2D texture. +fn dx10_encoding(b: &[u8]) -> Result { + let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?); + if misc & MISC_TEXTURECUBE != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if dimension != DIMENSION_TEXTURE2D { + return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})"))); + } + if array > 1 { + return Err(unsupported(format!("texture arrays are not supported ({array} textures)"))); + } + const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0]; + match dxgi { + 27..=29 => Encoding::masks(32, RGBA, false), + 87 | 90 | 91 => Encoding::masks(32, BGRA, false), + 88 | 92 | 93 => Encoding::masks(32, BGRX, false), + 70..=72 => Ok(Encoding::Bc1), + 73..=75 => Ok(Encoding::Bc2), + 76..=78 => Ok(Encoding::Bc3), + _ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))), + } +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !b.starts_with(b"DDS ") { + return Err(bad("missing the `DDS ` signature")); + } + if le32(b, 4)? != 124 { + return Err(bad("header size is not 124")); + } + let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?); + if caps2 & DDSCAPS2_CUBEMAP != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) { + return Err(unsupported("volume textures are not supported")); + } + let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10"; + let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; + let layout = encoding.layout(); + limits.check(width, height, layout, SampleType::U8)?; + let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; + let (w, h) = (width as usize, height as usize); + let channels = layout.channels(); + let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let mut pixels = vec![0u8; total]; + match encoding { + Encoding::Masks { bytes, gray, fields } => { + for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 }; + let blocks_x = w.div_ceil(4); + for (i, block) in data.chunks_exact(block_size).enumerate() { + let texels = match encoding { + Encoding::Bc1 => bc1(block, true), + Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)), + _ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)), + }; + let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4); + for (t, texel) in texels.iter().enumerate() { + let (x, y) = (bx + t % 4, by + t / 4); + if x < w && y < h { + let at = (y * w + x) * 4; + if let Some(dst) = pixels.get_mut(at..at + 4) { + dst.copy_from_slice(texel); + } + } + } + } + } + } + Image::from_u8(width, height, layout, pixels) +} + +fn rgb565(c: u16) -> [u8; 3] { + let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; + [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] +} + +/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's +/// `c0 <= c1` order selects three colours and transparent black. +fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)])); + let (a, b) = (rgb565(c0), rgb565(c1)); + let mix = |wa: u32, wb: u32| { + let d = wa + wb; + let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; + [at(0), at(1), at(2), 255] + }; + let palette = if c0 > c1 || !one_bit_alpha { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] + } else { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] + }; + let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]); + std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) +} + +/// BC2: four explicit bits of alpha per texel. +fn bc2_alpha(block: &[u8]) -> [u8; 16] { + let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0))); + std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17) +} + +/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus +/// 0 and 255 when `a0 <= a1`. +fn bc3_alpha(block: &[u8]) -> [u8; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1))); + let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; + if a0 > a1 { + for (k, v) in palette.iter_mut().enumerate().skip(2) { + let k = k as u32; + *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; + } + } else { + for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { + let k = k as u32; + *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; + } + } + let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]); + std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8) +} + +fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { + for (texel, a) in texels.iter_mut().zip(alpha) { + texel[3] = a; + } + texels +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 16a0d0e159..b0b3ca46e9 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod dds; pub(crate) mod deep_exr; pub(crate) mod dpx; pub(crate) mod exr; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index e1e0927fad..dca235f80f 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -19,6 +19,8 @@ pub enum Format { Pcx, /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, + /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3. + Dds, Qoi, OpenExr, /// Radiance RGBE. @@ -67,6 +69,10 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", ), + ( + Format::Dds, + "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.", + ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ @@ -89,7 +95,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +107,7 @@ impl Format { Format::Pnm, Format::Pcx, Format::Xpm, + Format::Dds, Format::Qoi, Format::OpenExr, Format::Hdr, @@ -133,6 +140,7 @@ impl Format { Format::Pnm => "Netpbm", Format::Pcx => "PCX", Format::Xpm => "XPM", + Format::Dds => "DDS", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", @@ -158,6 +166,7 @@ impl Format { Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], Format::Pcx => &["pcx"], Format::Xpm => &["xpm"], + Format::Dds => &["dds"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], @@ -181,6 +190,7 @@ impl Format { Format::Pnm => "image/x-portable-anymap", Format::Pcx => "image/x-pcx", Format::Xpm => "image/x-xpixmap", + Format::Dds => "image/vnd-ms.dds", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", @@ -232,6 +242,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, + Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -267,6 +278,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"qoif") { return Some(Format::Qoi); } + if b.starts_with(b"DDS ") { + return Some(Format::Dds); + } if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) { return Some(Format::OpenExr); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3974929ee0..ec663ace78 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), Format::Xpm => xpm::decode(bytes, l), + Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), @@ -227,6 +228,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), + Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs new file mode 100644 index 0000000000..766555f6e5 --- /dev/null +++ b/crates/codecs/tests/dds.rs @@ -0,0 +1,268 @@ +use photocraft_codecs::{ + ChannelLayout, CodecError, DecodeOptions, Format, Limits, SampleType, caps, decode, decode_as, decode_as_with, detect, encode, from_extension, +}; + +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; + +/// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. +fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { + let mut b = vec![0u8; 128]; + b[0..4].copy_from_slice(b"DDS "); + let mut put = |at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + put(4, 124); + put(8, 0x1007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT + put(12, height); + put(16, width); + put(76, 32); + put(80, pf_flags); + put(88, bits); + for (i, m) in masks.into_iter().enumerate() { + put(92 + 4 * i, m); + } + put(108, 0x1000); // DDSCAPS_TEXTURE + b[84..88].copy_from_slice(fourcc); + b +} + +fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { + header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) +} + +/// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. +fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { + let mut b = compressed(width, height, b"DX10"); + for v in [dxgi, dimension, misc, array, 0] { + b.extend(v.to_le_bytes()); + } + b +} + +/// BC1 colour endpoints as RGB565, then one 2-bit index per pixel, row by row. +fn bc1_block(c0: u16, c1: u16, rows: [u8; 4]) -> Vec { + let mut b = Vec::new(); + b.extend(c0.to_le_bytes()); + b.extend(c1.to_le_bytes()); + b.extend(rows); + b +} + +const RED: u16 = 0xf800; +const GREEN: u16 = 0x07e0; +const BLUE: u16 = 0x001f; +const BLACK: u16 = 0x0000; +const WHITE: u16 = 0xffff; + +/// Indices 0, 1 on the top row and 2, 3 below them, for the top-left 2x2 pixels. +const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0]; + +#[test] +fn dds_is_readable_but_not_writable() { + assert_eq!(from_extension("texture.DDS"), Some(Format::Dds)); + assert!(caps(Format::Dds).read && !caps(Format::Dds).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. }))); +} + +#[test] +fn decodes_bc1_four_colour_blocks() { + // c0 > c1: four opaque colours, the two in between at thirds. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(RED, BLUE, INDICES_0123)); + assert_eq!(detect(&b), Some(Format::Dds)); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout(), im.sample_type()), ((2, 2), ChannelLayout::Rgba, SampleType::U8)); + assert_eq!(im.data(), &[255, 0, 0, 255, 0, 0, 255, 255, 170, 0, 85, 255, 85, 0, 170, 255]); +} + +#[test] +fn decodes_bc1_one_bit_alpha_blocks() { + // c0 <= c1: three colours (the third halfway) and index 3 is transparent black. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn decodes_bc2_explicit_alpha_with_four_colour_blocks() { + let mut b = compressed(2, 2, b"DXT3"); + // Four bits of alpha per pixel: 15, 0 on the top row, 8, 1 below. + b.extend([0x0f, 0x00, 0x18, 0x00, 0, 0, 0, 0]); + // c0 < c1 would mean transparency in BC1; BC2 colour blocks always have four colours. + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 136, 85, 85, 85, 17]); +} + +#[test] +fn decodes_bc3_interpolated_alpha() { + // a0 > a1: eight alpha values, six interpolated at sevenths. Indices 0, 1 / 2, 7. + let mut b = compressed(2, 2, b"DXT5"); + b.extend([255, 0, 0x08, 0xa0, 0x03, 0, 0, 0]); + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 219, 85, 85, 85, 36]); + + // a0 <= a1: six values, four interpolated at fifths, then 0 and 255. Indices 2, 5 / 6, 7. + let mut b = compressed(2, 2, b"DXT5"); + let bits: u64 = 2 | 5 << 3 | 6 << 12 | 7 << 15; + b.extend([0, 255]); + b.extend(&bits.to_le_bytes()[..6]); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + assert_eq!(decode(&b).unwrap().data(), &[0, 255, 0, 51, 0, 255, 0, 204, 0, 255, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn bc_textures_need_not_be_a_multiple_of_four() { + // 5x3 is two blocks wide and one high; the right block's first column is x = 4. + let mut b = compressed(5, 3, b"DXT1"); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (5, 3)); + let px = |x: usize, y: usize| &im.data()[(y * 5 + x) * 4..(y * 5 + x + 1) * 4]; + assert_eq!((px(3, 2), px(4, 0), px(4, 2)), (&[255, 0, 0, 255][..], &[0, 0, 255, 255][..], &[0, 0, 255, 255][..])); +} + +#[test] +fn decodes_uncompressed_rgba_and_bgra_masks() { + let rgba = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, rgba); + b.extend([1, 2, 3, 4, 250, 251, 252, 253]); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + let bgra = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 4, 252, 251, 250, 253]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + // An alpha mask without DDPF_ALPHAPIXELS is not alpha: X8R8G8B8 opens as RGB. + let mut b = header(1, 1, DDPF_RGB, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 0]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..])); +} + +#[test] +fn decodes_packed_masks_and_luminance() { + // R5G6B5: each field scaled to 0..255; 3 of 31 is 25. + let mut b = header(4, 1, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + for v in [RED, GREEN, BLUE, 3 << 11] { + b.extend(v.to_le_bytes()); + } + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgb); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 0, 0]); + + // 24-bit rows are packed three bytes a pixel, without padding. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([3, 2, 1, 6, 5, 4, 9, 8, 7, 12, 11, 10]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + + let mut b = header(2, 1, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + b.extend([10, 20, 30, 40]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_dx10_headers() { + let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM + b.extend([1, 2, 3, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(1, 1, 91, 3, 0, 1); // B8G8R8A8_UNORM_SRGB + b.extend([3, 2, 1, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(2, 2, 71, 3, 0, 1); // BC1_UNORM + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn only_the_top_mip_level_is_read() { + let mut b = compressed(4, 4, b"DXT1"); + b[28..32].copy_from_slice(&3u32.to_le_bytes()); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (4, 4)); + assert!(im.data().chunks(4).all(|p| p == [255, 0, 0, 255])); +} + +#[test] +fn refuses_cubemaps_arrays_and_volumes() { + let unsupported = |b: &[u8], what: &str| match decode_as(Format::Dds, b) { + Err(CodecError::Unsupported { reason, .. }) => assert!(reason.contains(what), "{reason}"), + other => panic!("{what}: {other:?}"), + }; + let mut cube = compressed(4, 4, b"DXT1"); + cube[112..116].copy_from_slice(&0xfe00u32.to_le_bytes()); // DDSCAPS2_CUBEMAP and all faces + cube.extend([0; 8 * 6]); + unsupported(&cube, "cubemap"); + let mut volume = compressed(4, 4, b"DXT1"); + volume[8..12].copy_from_slice(&(0x1007u32 | 0x80_0000).to_le_bytes()); // DDSD_DEPTH + volume[24..28].copy_from_slice(&4u32.to_le_bytes()); + volume[112..116].copy_from_slice(&0x20_0000u32.to_le_bytes()); // DDSCAPS2_VOLUME + volume.extend([0; 8 * 4]); + unsupported(&volume, "volume"); + unsupported(&dx10(4, 4, 71, 3, 0x4, 1), "cubemap"); + unsupported(&dx10(4, 4, 71, 3, 0, 6), "array"); + unsupported(&dx10(4, 4, 71, 4, 0, 1), "2D"); + unsupported(&dx10(4, 4, 98, 3, 0, 1), "98"); // BC7_UNORM + unsupported(&compressed(4, 4, b"ATI2"), "ATI2"); + unsupported(&compressed(4, 4, b"DXT2"), "premultiplied"); +} + +#[test] +fn rejects_truncated_files_at_every_length_without_panicking() { + let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks + b.extend([0x5a; 32]); + assert!(decode(&b).is_ok()); + for n in 0..b.len() { + assert!(decode_as(Format::Dds, &b[..n]).is_err(), "decoded {n} of {} bytes", b.len()); + } + let mut b = header(3, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([7; 18]); + assert!(decode(&b).is_ok()); + assert!(decode_as(Format::Dds, &b[..b.len() - 1]).is_err(), "one byte short"); +} + +#[test] +fn rejects_bogus_sizes_and_pixel_formats() { + // Huge dimensions are refused before anything is allocated. + let mut huge = compressed(u32::MAX, u32::MAX, b"DXT1"); + huge.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &huge), Err(CodecError::LimitExceeded(_)))); + let opts = DecodeOptions { limits: Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() }; + let mut tight = header(64, 64, DDPF_RGB, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + tight.extend(vec![0; 64 * 64 * 4]); + assert!(matches!(decode_as_with(Format::Dds, &tight, &opts), Err(CodecError::LimitExceeded(_)))); + assert!(decode_as(Format::Dds, &compressed(0, 4, b"DXT1")).is_err(), "zero width"); + + let mut bad_size = compressed(4, 4, b"DXT1"); + bad_size[4..8].copy_from_slice(&100u32.to_le_bytes()); + bad_size.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &bad_size), Err(CodecError::Malformed { .. })), "header size must be 124"); + + for (bits, masks) in [ + (12, [0xf00, 0xf0, 0xf, 0]), // not a whole number of bytes + (16, [0xff_0000, 0xff00, 0xff, 0]), // a mask wider than the pixel + (32, [0xf0f, 0xf0f0, 0xf_0000, 0]), // a mask with a gap + (32, [0, 0, 0, 0]), // no colour at all + (0, [0xff, 0xff00, 0xff_0000, 0]), // zero bits per pixel + ] { + let mut b = header(1, 1, DDPF_RGB, &[0; 4], bits, masks); + b.extend([0; 4]); + assert!(decode_as(Format::Dds, &b).is_err(), "{bits} bits, masks {masks:x?}"); + } + let mut no_format = header(1, 1, 0, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + no_format.extend([0; 4]); + assert!(matches!(decode_as(Format::Dds, &no_format), Err(CodecError::Unsupported { .. }))); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 39f7ece88f..4cb21759ec 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-12 · **Change:** minor (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-12 · **Change:** minor (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 @@ -90,6 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged. | Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) | | OpenRaster `.ora` | yes | yes | [ora.md](ora.md) | | ICO, QOI | yes | yes | | +| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in | | Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either | ### Affinity import notes (moved from `roadmap.md`) @@ -141,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | DDS textures open, read-only (#1327) | | 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | From f5d64a66bdb24471740a67e2f889d00c8e7475c4 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:18 +0200 Subject: [PATCH 094/223] Color panel: the spectrum field in every theme (#4367) (#4399) --- crates/ui-egui/src/color_tab_theme_tests.rs | 55 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/panels.rs | 25 +++++----- 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/color_tab_theme_tests.rs diff --git a/crates/ui-egui/src/color_tab_theme_tests.rs b/crates/ui-egui/src/color_tab_theme_tests.rs new file mode 100644 index 0000000000..b3566277e2 --- /dev/null +++ b/crates/ui-egui/src/color_tab_theme_tests.rs @@ -0,0 +1,55 @@ +//! The Color panel tab draws Photoshop's spectrum field in every theme (#4367): the light themes +//! used to show HSB sliders only. Those themes keep the sliders, under the field. + +use egui::{Rect, pos2}; +use egui_kittest::kittest::Queryable; + +use crate::PhotocraftApp; +use crate::dock::Group; +use crate::theme::{ThemeKind, Tokens}; + +fn left_top() -> egui::Id { + egui::Id::new("color-tab-test-origin") +} + +/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground. +fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + let tab = Group::Color.tabs(Tokens::for_kind(kind).pro).iter().position(|name| *name == "Color").unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(300.0, 400.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + let origin = ui.cursor().min; + ui.data_mut(|d| d.insert_temp(left_top(), Rect::from_min_size(origin, egui::Vec2::ZERO))); + crate::panels::dock_body(app, ui, Group::Color, tab); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + h +} + +#[test] +fn every_theme_shows_the_spectrum_field() { + for kind in ThemeKind::ALL { + let mut h = color_tab(kind); + let origin = h.ctx.data(|d| d.get_temp::(left_top())).unwrap().min; + // Left edge of the saturation/brightness field (after the 38 pt chips and the 8 pt gap), + // halfway down its 120 pt: no saturation, half brightness. + let p = pos2(origin.x + 38.0 + 8.0 + 1.0, origin.y + 60.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(2); + let [r, g, b, _] = h.state().session.tools.foreground; + assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && (0.3..0.7).contains(&r), "{kind:?}: the field picks a mid grey, got {r} {g} {b}"); + if !Tokens::for_kind(kind).pro { + assert!(h.query_by_label("Saturation").is_some(), "{kind:?}: the sliders stay under the field"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0aa5d4e9b..d038859375 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2369,6 +2369,9 @@ mod move_auto_select_tests; #[cfg(test)] mod new_group_button_tests; +#[cfg(test)] +mod color_tab_theme_tests; + #[cfg(test)] mod new_layer_button_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 30a3bb400d..7ee7be0844 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1963,7 +1963,16 @@ fn empty(ui: &mut egui::Ui, s: &str) { ui.add_space(6.0); } +/// The Color tab of the themes other than Photoshop's: the same spectrum field (chips, saturation +/// and brightness field, hue strip and readout) with HSB sliders under it (#4367). fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + color_field(app, ui); + ui.add_space(8.0); + hsb_sliders(app, ui); +} + +/// Hue, Saturation and Brightness sliders for the foreground colour. +fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fg = app.session.tools.foreground; // Keep the last edited HSB while it still gives the foreground: black and greys have no hue or // saturation of their own, so recomputing them from RGB would reset what was just typed to 0. @@ -1990,16 +1999,6 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, hsva)); crate::type_tool::foreground_changed(app); } - ui.horizontal(|ui| { - let (sw, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); - ui.painter().rect_filled(sw, 6.0, hsva_color32(hsva)); - let mut c = app.session.tools.foreground; - if color_readout(ui, ui.id().with("color-panel"), &mut c) { - app.session.tools.foreground = c; - ui.data_mut(|d| d.insert_temp(key, srgb_hsva(c))); - crate::type_tool::foreground_changed(app); - } - }); } // ----------------------------------------------------------------------------- layers @@ -3358,7 +3357,9 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let strip_w = 14.0; // Photoshop's Color panel field fills the panel: take the height the dock offers, less the // hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt. - let h = color_field_height(ui.data(|d| d.get_temp::(color_field_fill_id())), w); + // Only Photoshop's themes stretch it over the group: the other themes put sliders under it. + let fill = if t.pro { ui.data(|d| d.get_temp::(color_field_fill_id())) } else { None }; + let h = color_field_height(fill, w); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover()); @@ -4123,7 +4124,7 @@ mod color_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - color_picker(app, ui); + hsb_sliders(app, ui); } }, app, From 3f2ac1601e2d8a6d7817362d94b2fdd47d5af5a5 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 18:47:23 -0700 Subject: [PATCH 095/223] macOS: big brushes keep a native outline too, and big cursor bitmaps rasterise 7x faster (#1343) (#4401) On macOS the brush outline fell back to the app-drawn outline past 255 points, and that outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a 1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware cursor planes. A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted: a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn tip outlines likewise only write the pixels their segments splat. --- crates/ui-egui/src/tool_cursor.rs | 87 ++++++++++++++++++------- crates/ui-egui/src/tool_cursor/tests.rs | 30 ++++++++- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index da78f1160b..acf0374807 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -11,12 +11,14 @@ use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; mod hand; -/// The largest cursor bitmap, in physical pixels; a larger tip paints its outline instead (with a -/// small OS crosshair at the hotspot). Windows takes winit's limit. Elsewhere, X servers and -/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors -/// (or reject them) past that, so stay within it there (#968 used 128). Either way it keeps -/// allocations bounded for hostile brush sizes or zooms. -const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 }; +/// The largest cursor bitmap (physical pixels; points on macOS, see [`native_scale`]); a larger tip +/// paints its outline instead, with a small OS crosshair at the hotspot, and that outline trails +/// the pointer by a frame or two (26 ms on a 120 Hz Mac, #1343). Windows takes winit's limit, and +/// so does macOS: AppKit sets no size limit on an `NSCursor` image. On Linux, X servers and +/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors (or +/// reject them) past that, so stay within it there (#968 used 128). Either way it keeps allocations +/// bounded for hostile brush sizes or zooms. +const MAX_SIDE: u16 = if cfg!(any(target_os = "windows", target_os = "macos")) { 2048 } else { 255 }; /// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags. /// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot. @@ -308,13 +310,27 @@ fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) { } /// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form). -fn push_stroke_pixel(rgba: &mut Vec, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) { - let (fill, outline) = stroke_coverage(distance, scale); +/// A pixel with light `fill` over a dark `outline` (coverages 0 to 1), in straight RGBA as winit +/// requires (not egui's premultiplied). No coverage is `[0; 4]`. +fn stroke_pixel((fill, outline): (f32, f32), dark: egui::Color32, light: egui::Color32) -> [u8; 4] { let a_dark = outline * f32::from(dark.a()) / 255.0; let a_light = fill * f32::from(light.a()) / 255.0; let alpha = a_light + a_dark * (1.0 - a_light); let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + [white, white, white, (255.0 * alpha).round() as u8] +} + +/// The columns of row offset `dy` (from the hotspot, in pixels) that can be painted by a ring of +/// radius `r` drawn `reach` wide either side: the left and right arcs of the annulus, padded by +/// a pixel. Every other pixel of the row is transparent. +fn ring_spans(dy: f32, r: f32, reach: f32) -> Option<[(f32, f32); 2]> { + let (ro, ri) = (r + reach + 1.0, (r - reach - 1.0).max(0.0)); + if dy.abs() > ro { + return None; + } + let xo = (ro * ro - dy * dy).max(0.0).sqrt(); + let xi = if dy.abs() < ri { (ri * ri - dy * dy).sqrt() } else { 0.0 }; + Some([(-xo, -xi), (xi, xo)]) } fn rasterize(shape: &Shape, scale: f32) -> Option { @@ -331,13 +347,20 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { let hot = half as u16; let side = hot * 2 + 1; let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?; - let mut rgba = Vec::with_capacity(bytes); + // Transparent to start; only pixels a shape can reach are evaluated below (a big tip's + // outline is a thin ring in a large square, #1343). + let mut rgba = vec![0u8; bytes]; let [dark, light] = crate::theme::Tokens::cursor_outline(); + let n = usize::from(side); + let hotf = f32::from(hot); + let mut put = |col: usize, row: usize, cov: (f32, f32)| { + if let Some(px) = row.checked_mul(n).and_then(|i| i.checked_add(col)).and_then(|i| rgba.get_mut(i * 4..i * 4 + 4)) { + px.copy_from_slice(&stroke_pixel(cov, dark, light)); + } + }; if let Shape::Outline { rings, centre } = shape { // Splat each segment into its bounding box instead of measuring every ring per pixel: // tip outlines may carry hundreds of points. - let hotf = f32::from(hot); - let n = usize::from(side); let reach = 1.5 * scale + 2.0; let mut dist = vec![f32::INFINITY; n * n]; for ring in rings.iter() { @@ -352,22 +375,36 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { if *centre { splat_cross(&mut dist, n, hotf, 3.0 * scale); } - for &d in &dist { - push_stroke_pixel(&mut rgba, d, scale, dark, light); + for (i, &d) in dist.iter().enumerate() { + if d.is_finite() { + put(i % n, i / n, stroke_coverage(d, scale)); + } } return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }); } - for row in 0..side { - let dy = f32::from(row) - f32::from(hot); - for col in 0..side { - let dx = f32::from(col) - f32::from(hot); - let (fill, outline) = coverage(shape, dx, dy, scale); - let a_dark = outline * f32::from(dark.a()) / 255.0; - let a_light = fill * f32::from(light.a()) / 255.0; - let alpha = a_light + a_dark * (1.0 - a_light); - // Straight RGBA, as required by winit, not egui's premultiplied representation. - let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + // Columns to evaluate in each row: the ring's two arcs (and the centre mark) for a circle, + // the whole row otherwise. + let last = f32::from(side - 1); + for row in 0..n { + let dy = row as f32 - hotf; + let mut spans: [Option<(f32, f32)>; 3] = [None; 3]; + if let Shape::Circle { radius, centre } = shape { + let reach = 1.5 * scale + 0.5; + if let Some([l, r]) = ring_spans(dy, radius * scale, reach) { + spans = [Some(l), Some(r), None]; + } + let cross = 3.0 * scale + reach + 1.0; + if *centre && dy.abs() <= cross { + spans[2] = Some((-cross, cross)); + } + } else { + spans[0] = Some((-hotf, hotf)); + } + for (a, b) in spans.into_iter().flatten() { + let (c0, c1) = ((hotf + a).floor().clamp(0.0, last) as usize, (hotf + b).ceil().clamp(0.0, last) as usize); + for col in c0..=c1 { + put(col, row, coverage(shape, col as f32 - hotf, dy, scale)); + } } } Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 3a8bd1f010..54168f537d 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -48,14 +48,17 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } -/// Off Windows a tip stays within what X servers and hardware cursor planes take (256 px); a -/// larger one falls back to the painted outline. Windows takes far larger cursors. +/// On Linux a tip stays within what X servers and hardware cursor planes take (256 px); a larger +/// one falls back to the painted outline. Windows and macOS take far larger cursors. #[test] fn cursor_bitmaps_stay_within_the_platform_limit() { let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]); + let large = cfg!(any(target_os = "windows", target_os = "macos")); assert!(fits(100.0).is_some_and(|side| side <= 255)); - assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows")); + assert_eq!(fits(200.0).is_some(), large); + assert_eq!(fits(1000.0).is_some(), large, "a 2000 px brush at 100 % still has a native outline"); assert!(fits(200.0).is_none_or(|side| side <= 2048)); + assert!(fits(1100.0).is_none(), "nothing past 2048 px"); } #[test] @@ -340,3 +343,24 @@ fn painting_preference_and_zoom_change_the_native_cursor_without_changing_pointe h.run_steps(2); assert!(h.output().platform_output.cursor_image.as_ref().unwrap().size[0] > cross_size); } + +/// A big tip's outline is a thin ring in a large square: only the ring (and the centre mark) is +/// evaluated, and the bitmap is exactly what evaluating every pixel gives. +#[test] +fn circle_bitmaps_evaluate_only_the_ring_and_match_every_pixel() { + let [dark, light] = crate::theme::Tokens::cursor_outline(); + for (radius, scale, centre) in [(0.4, 1.0, true), (3.0, 1.0, false), (20.0, 1.25, true), (57.3, 2.0, false), (90.0, 1.0, true)] { + let shape = Shape::Circle { radius, centre }; + let image = rasterize(&shape, scale).unwrap(); + let [hot, _] = image.hotspot; + let side = usize::from(image.size[0]); + for row in 0..side { + for col in 0..side { + let (dx, dy) = (col as f32 - f32::from(hot), row as f32 - f32::from(hot)); + let want = stroke_pixel(coverage(&shape, dx, dy, scale), dark, light); + let i = (row * side + col) * 4; + assert_eq!(image.rgba[i..i + 4], want, "radius {radius} at {scale}×, pixel ({col}, {row})"); + } + } + } +} From 851916e0d7dfcc939c44d0b72a48eac2c5701685 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:27 +0200 Subject: [PATCH 096/223] Save As: offer ICO, GIF and BMP in the file type list (#4357) (#4405) --- apps/photocraft/src/services.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 7ec91954b1..f892de31cd 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -60,6 +60,10 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("WebP", &["webp"]), ("TIFF", &["tif"]), ("Targa", &["tga"]), + // Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha. + ("BMP", &["bmp"]), + ("GIF", &["gif"]), + ("ICO", &["ico"]), ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), @@ -670,6 +674,33 @@ mod tests { assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default"); } + /// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357). + #[test] + fn save_as_offers_only_writable_formats_including_ico() { + let layered = ["psd", "psb", "pcraft", "ora"]; + for (name, exts) in SAVE_FILTERS { + for ext in exts.iter().filter(|e| !layered.contains(e)) { + let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format")); + assert!(format.caps().write, "{name}: .{ext} can't be written"); + } + } + for ext in ["ico", "gif", "bmp"] { + assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); + } + assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); + // What Save As writes there: an icon up to 256×256, and a clear error past that. + let doc = |side: u32| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": side, "height": side})).unwrap(); + s.active().unwrap().doc.clone() + }; + let opts = export_options(&photocraft_ui_egui::ExportSettings::default()); + let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap(); + assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header"); + let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string(); + assert!(err.contains("256"), "{err}"); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); From 6c2934ba797cb75b8832b6e31f4c2e9ffb0c65bb Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:32 +0200 Subject: [PATCH 097/223] Recent files: a saved document is listed, as an opened one is (#4400) (#4411) --- crates/ui-egui/src/file_open/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/lib.rs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index d49ab58206..9a833b7ba1 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -776,3 +776,20 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { jobs_ui::finish_open_tab(&mut app, &late, &json!({"document": index})).unwrap(); assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } + +/// #4400: a new document saved under a name is listed in Open Recent (and on the Home screen), +/// as opening it is; a Save a Copy isn't, since the document doesn't live there. +#[test] +fn saving_a_new_document_lists_it_in_recent_files() { + let (mut app, _written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/new.psd".into())), Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + assert!(app.ui.recent_files.is_empty()); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"]); + assert_eq!(app.session.prefs().file_handling.recent_files, ["/pics/new.psd"], "kept for the next launch"); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"], "a copy isn't the document's file"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d038859375..b6d844b80f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1450,6 +1450,11 @@ impl PhotocraftApp { } Ok(()) }); + // The file the document now lives in goes to the top of Open Recent and the Home + // screen, as opening it does (#4400). Automation saves don't touch the user's list. + if hooks { + self.push_recent(path); + } } self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); From 98ec507375ac36d9162502f6330e6ef49dd3cd87 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:47:36 -0600 Subject: [PATCH 098/223] Paths: curves stay smooth when zoomed in (#4084) (#4445) The path overlays sampled every Bezier segment at a fixed 16 points (24 for the Curvature Pen's preview) in document space, so zoomed in a curve showed as visible straight chords with corners. curve_points maps a segment's four points to the screen (the view is affine, so the curve maps exactly) and picks the number of chords from how much it bends there: the chord error stays within 0.3 px (the bound the vector crate's flattening uses), capped at 1024 chords. A straight segment, with handles retracted or along the line, is one chord. Used by path_lines (work path, shape and vector mask outlines, Direct Selection, the shape preview, Paths thumbnails), the Pen's path in progress and the Curvature Pen's preview. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 105 +++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index a13f8fa569..d4de249202 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -763,6 +763,45 @@ fn bezier(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], t: f64) -> [f6 [f(p0[0], c0[0], c1[0], p1[0]), f(p0[1], c0[1], c1[1], p1[1])] } +/// `q` lies on the chord from `a` to `d` (within a thousandth of a pixel). +fn on_chord(a: [f64; 2], q: [f64; 2], d: [f64; 2]) -> bool { + let (dx, dy, vx, vy) = (d[0] - a[0], d[1] - a[1], q[0] - a[0], q[1] - a[1]); + let len = dx.hypot(dy); + if len < 1e-9 { + return vx.hypot(vy) < 1e-3; + } + let t = (vx * dx + vy * dy) / (len * len); + (vx * dy - vy * dx).abs() / len < 1e-3 && (-1e-9..=1.0 + 1e-9).contains(&t) +} + +/// How far (in screen pixels) a drawn chord may stray from the true curve. +const CURVE_TOLERANCE: f64 = 0.3; +/// Pieces per segment at most: 0.3 px holds for curves bending up to about 1.4 million px on screen. +const MAX_CURVE_PIECES: usize = 1024; + +/// Screen points along one cubic segment (both ends included). The control points are mapped to +/// the screen first (`xf` is affine, so the curve maps exactly), and the number of chords follows +/// how much the curve bends there: a straight segment is one chord, and a curve zoomed in stays +/// smooth instead of showing a fixed 16 chords (#4084). Uses the bound +/// `max|B''| / 8n² <= tol` with `max|B''| <= 6·max(|p0−2c0+c1|, |c0−2c1+p1|)`. +pub(crate) fn curve_points(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec { + let [a, b, c, d] = [p0, c0, c1, p1].map(|q| { + let s = xf(q); + [f64::from(s.x), f64::from(s.y)] + }); + let second = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (p[0] - 2.0 * q[0] + r[0]).hypot(p[1] - 2.0 * q[1] + r[1]); + let n = (0.75 * second(a, b, c).max(second(b, c, d)) / CURVE_TOLERANCE).sqrt().ceil(); + // A straight segment (controls on the chord, like the Pen's retracted handles) is one chord; + // NaN (a non-finite point) falls back to one too. + let n = if n.is_finite() && !(on_chord(a, b, d) && on_chord(a, c, d)) { (n as usize).clamp(1, MAX_CURVE_PIECES) } else { 1 }; + (0..=n) + .map(|k| { + let q = bezier(a, b, c, d, k as f64 / n as f64); + pos2(q[0] as f32, q[1] as f32) + }) + .collect() +} + /// Screen polylines for a path (one per subpath). pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec, bool)> { path.subpaths @@ -773,11 +812,7 @@ pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec let segs = if s.closed { n } else { n.saturating_sub(1) }; for i in 0..segs { let (a, b) = (&s.knots[i], &s.knots[(i + 1) % n]); - for k in 0..=16 { - let q = - bezier([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], k as f64 / 16.0); - pts.push(xf(q)); - } + pts.extend(curve_points([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], xf)); } if pts.is_empty() && let Some(k) = s.knots.first() @@ -866,9 +901,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform first[2] = out; } for w in knots.windows(2) { - for k in 0..=16 { - pts.push(to_scr(bezier(w[0][0], w[0][2], w[1][1], w[1][0], k as f64 / 16.0))); - } + pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) @@ -881,7 +914,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform next.knots.push([h; 3]); curvature_refresh(&mut next, false); if let [.., a, b] = next.knots.as_slice() { - let pts: Vec = (0..=24).map(|k| to_scr(bezier(a[0], a[2], b[1], b[0], k as f64 / 24.0))).collect(); + let pts = curve_points(a[0], a[2], b[1], b[0], &to_scr); painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); } } else { @@ -1707,6 +1740,60 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #4084: zoomed in, a curve's chords stay within a fraction of a pixel of the true curve + /// (16 fixed chords showed corners), and a straight segment stays one chord. + #[test] + fn path_curves_stay_smooth_at_any_zoom() { + let (p0, c0, c1, p1) = ([0.0, 100.0], [20.0, -60.0], [80.0, -60.0], [100.0, 100.0]); + for zoom in [0.25, 1.0, 8.0, 64.0, 320.0] { + let xf = move |q: [f64; 2]| pos2((q[0] * zoom) as f32 + 10.0, (q[1] * zoom) as f32 - 5.0); + let pts = curve_points(p0, c0, c1, p1, &xf); + let n = pts.len() - 1; + assert_eq!((pts[0], pts[n]), (xf(p0), xf(p1)), "the ends are the anchors"); + for (k, w) in pts.windows(2).enumerate() { + let curve = xf(bezier(p0, c0, c1, p1, (k as f64 + 0.5) / n as f64)); + let chord = w[0] + (w[1] - w[0]) * 0.5; + assert!((chord - curve).length() <= 0.35, "zoom {zoom}: chord {k} of {n} is {} px off", (chord - curve).length()); + } + // Zoomed out it stays cheap. + if zoom <= 1.0 { + assert!(n <= 32, "zoom {zoom}: {n} chords"); + } + } + let xf = |q: [f64; 2]| pos2((q[0] * 320.0) as f32, (q[1] * 320.0) as f32); + // Retracted handles, and handles along the line: one chord. + assert_eq!(curve_points([0.0, 0.0], [0.0, 0.0], [50.0, 50.0], [50.0, 50.0], &xf).len(), 2); + assert_eq!(curve_points([0.0, 0.0], [10.0, 10.0], [40.0, 40.0], [50.0, 50.0], &xf).len(), 2); + // A handle past the end is a curve that doubles back, not a line. + assert!(curve_points([0.0, 0.0], [0.0, 0.0], [80.0, 80.0], [50.0, 50.0], &xf).len() > 2); + // Hostile coordinates: no panic, a bounded number of points. + for bad in [f64::NAN, f64::INFINITY, 1e300] { + assert!(curve_points([0.0, 0.0], [bad, 0.0], [0.0, bad], [1.0, 1.0], &xf).len() <= MAX_CURVE_PIECES + 1); + } + // Through path_lines too: a closed subpath of two curved segments. + let mut path = Path::default(); + path.subpaths.push(photocraft_doc::vector::Subpath { + knots: vec![ + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(0.0, 0.0), + in_ctrl: photocraft_geom::Point::new(0.0, 40.0), + out_ctrl: photocraft_geom::Point::new(0.0, -40.0), + smooth: true, + }, + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -40.0), + out_ctrl: photocraft_geom::Point::new(60.0, 40.0), + smooth: true, + }, + ], + closed: true, + ..Default::default() + }); + let lines = path_lines(&path, &xf); + assert!(lines[0].1 && lines[0].0.len() > 2 * 17, "zoomed in, more than the old 16 chords per segment: {}", lines[0].0.len()); + } + #[test] fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] { From 40a93b7dc44de2507356d5a40750c5629d3dc024 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:47:41 -0600 Subject: [PATCH 099/223] Toolbar: click and hold on Change Screen Mode opens its modes (#4444) (#4464) The button draws the flyout triangle, but its list was an egui context menu, which opens on right-click (or a touch long-press) only. Clicking and holding with the mouse did nothing until release, which then cycled the screen mode. A hold of 0.35 s, the same as the tool flyouts, now opens the list, and the release that ends the hold neither cycles the mode nor closes the list. A plain click still cycles (F), and right-click still opens it. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 90 ++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7ee7be0844..1cc16a1fa3 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -298,11 +298,36 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); paint_toolbar_flyout_indicator(ui, &t, &sm); - if sm.clicked() { + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); } - // Right-click (or long-press) lists the modes, like Photoshop's flyout. - egui::Popup::context_menu(&sm).show(|ui| { + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { ui.set_min_width(220.0); for (id, label) in [ ("view.screenMode.standard", tl!("Standard Screen Mode")), @@ -4667,6 +4692,65 @@ mod type_flyout_tests { assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none()); } + /// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without + /// cycling the mode on release; a plain click still cycles, and right-click still opens it. + #[test] + fn long_press_screen_mode_opens_the_modes_without_cycling() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + // The toolbar's last button: Change Screen Mode, after Quick Mask. + let at = ctx + .viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .max_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left()))) + }) + .unwrap() + .center(); + let standard = |app: &PhotocraftApp| crate::view_cmds::checked(app, "view.screenMode.standard") == Some(true); + let menu_open = |ctx: &egui::Context| egui::Popup::is_any_open(ctx); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(standard(&app)); + // Hold, then release: the list opens and stays open, and the mode is unchanged. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 1.2, vec![]); + assert!(!menu_open(&ctx), "not before the hold time"); + frame(&mut app, &ctx, 1.4, vec![]); + assert!(menu_open(&ctx), "open while still held"); + frame(&mut app, &ctx, 1.5, vec![pointer(at, false)]); + frame(&mut app, &ctx, 1.55, vec![]); + assert!(menu_open(&ctx), "the release keeps it open"); + assert!(standard(&app), "the release doesn't cycle the mode"); + // Escape closes it; a plain click cycles the mode and opens nothing. + frame( + &mut app, + &ctx, + 2.0, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + frame(&mut app, &ctx, 2.05, vec![]); + assert!(!menu_open(&ctx)); + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 3.1, vec![pointer(at, false)]); + frame(&mut app, &ctx, 3.15, vec![]); + assert!(!standard(&app), "a click cycles"); + assert!(!menu_open(&ctx)); + // Right-click opens it too. + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 4.0, vec![secondary(true)]); + frame(&mut app, &ctx, 4.05, vec![secondary(false)]); + frame(&mut app, &ctx, 4.1, vec![]); + assert!(menu_open(&ctx)); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From 1bc7d7baad2b7b4e63f678de19c80ac4f8571a3e Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:45 +0200 Subject: [PATCH 100/223] =?UTF-8?q?Puppet=20Warp:=20=E2=8C=98Z=20/=20?= =?UTF-8?q?=E2=8C=98=E2=87=A7Z=20undo=20and=20redo=20pin=20edits=20(#4440)?= =?UTF-8?q?=20(#4479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/distort_ui.rs | 7 +- crates/ui-egui/src/puppet_ui.rs | 122 ++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 0a0d7914b0..9f0966b5c3 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -172,11 +172,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } - if app.distort.puppet.is_some() { + if let Some(p) = app.distort.puppet.as_mut() { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if enter(ctx) { crate::puppet_ui::commit(app); } else if esc(ctx) { app.distort.puppet = None; + } else if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + p.redo(); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + p.undo(); } return true; } diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index 42791715d6..b08546a51d 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -1,6 +1,7 @@ //! Edit › Puppet Warp on the canvas: click on the mesh to add pins, drag pins to move them, //! ⌥-click a pin to remove it; the options bar sets mode, density, expansion, mesh visibility, -//! the selected pin's depth and rotation. ↩ / Commit runs `edit.puppetWarp` with the pins. +//! the selected pin's depth and rotation. ⌘Z / ⌘⇧Z undo and redo pin edits inside the session +//! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! //! The preview is the layer texture on the deformed triangle mesh (an egui textured mesh, drawn //! by the GPU), re-solved with a few warm-started ARAP rounds per drag event (well under a @@ -37,9 +38,47 @@ pub struct PuppetSession { texture: TextureHandle, opacity: f32, pub preview_doc: Arc, + /// Pins before each edit of this session, newest last (⌘Z), and the edits undone (⌘⇧Z). + undo: Vec>, + redo: Vec>, + /// The pins when the current drag started: one undo step per drag that moved something. + drag_from: Option>, } impl PuppetSession { + /// Records `before` as one undoable pin edit. + fn record(&mut self, before: Vec) { + if before != self.warp.pins { + self.undo.push(before); + self.redo.clear(); + } + } + + /// Puts `pins` back (undo or redo), keeping the selection when that pin still exists. + fn restore(&mut self, pins: Vec) { + self.warp.pins = pins; + self.selected = self.selected.filter(|i| *i < self.warp.pins.len()); + self.drag = None; + self.drag_from = None; + self.rebind(); + } + + /// ⌘Z: the last pin edit undone. False when there is none. + pub fn undo(&mut self) -> bool { + let Some(pins) = self.undo.pop() else { return false }; + self.redo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + + /// ⌘⇧Z: the last undone pin edit redone. False when there is none. + pub fn redo(&mut self) -> bool { + let Some(pins) = self.redo.pop() else { return false }; + self.undo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + pub fn describe(&self) -> Value { json!({ "layer": self.layer.0, @@ -162,6 +201,9 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res texture, opacity, preview_doc, + undo: Vec::new(), + redo: Vec::new(), + drag_from: None, }; s.rebuild_mesh(); if s.mesh.tris.is_empty() { @@ -192,6 +234,14 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { return Ok(json!({"cancelled": true})); } let s = app.distort.puppet.as_mut().ok_or(tl!("Puppet Warp is not active"))?; + if ui.get("undo").and_then(Value::as_bool) == Some(true) { + s.undo(); + return Ok(s.describe()); + } + if ui.get("redo").and_then(Value::as_bool) == Some(true) { + s.redo(); + return Ok(s.describe()); + } let mut remesh = false; let (density, expansion) = (s.warp.density, s.warp.expansion); if let Some(m) = ui.get("mode").and_then(Value::as_str) { @@ -222,14 +272,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { match ev { ToolEvent::Down { x, y, .. } => { let p = [x, y]; + let before = s.warp.pins.clone(); if let Some(i) = s.pin_at(p, tol) { if mods.alt { s.warp.pins.remove(i); s.selected = None; s.rebind(); + s.record(before); } else { s.selected = Some(i); s.drag = Some(i); + s.drag_from = Some(before); } } else if !mods.alt && let Some(src) = s.rest_of(p) @@ -238,6 +291,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { s.selected = Some(s.warp.pins.len() - 1); s.drag = s.selected; s.rebind(); + // Placing a pin is one step; dragging it on from there is part of that step. + s.record(before); } } ToolEvent::Move { x, y, .. } => { @@ -255,6 +310,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { pin.dst = [x, y]; } s.solve(true); + if let Some(before) = s.drag_from.take() { + s.record(before); + } } } } @@ -305,6 +363,8 @@ pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(s) = app.distort.puppet.as_mut() else { return }; + // Pin depth, rotation and Remove All Pins are undoable pin edits too. + let pins_before = s.warp.pins.clone(); let mut remesh = false; let mut resolve = false; let mut rebind = false; @@ -369,6 +429,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { s.selected = None; rebind = true; } + s.record(pins_before); if remesh { // A refused value snaps the fields back to the previous settings. let _ = s.remesh_or_restore(density, expansion); @@ -393,6 +454,65 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #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] + fn pin_edits_undo_and_redo_inside_the_session() { + 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(); + let ev = |app: &mut PhotocraftApp, e| crate::distort_ui::pointer(app, e, egui::Modifiers::NONE); + let pins = |app: &PhotocraftApp| app.distort.puppet.as_ref().unwrap().warp.pins.iter().map(|p| p.dst).collect::>(); + ev(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 40.0 }); + // Drag the second pin up; then a click on it that moves nothing. + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: 75.0, y: 30.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 20.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers { alt: true, ..Default::default() }); + assert_eq!(pins(&app), [[75.0, 20.0]]); + // ⌘Z, through the keyboard: the removed pin comes back, then the drag is undone. + let press = |app: &mut PhotocraftApp, modifiers: egui::Modifiers| { + let ev = egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| assert!(crate::distort_ui::keys(app, ui.ctx()))).textures_delta.clear(); + }; + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 40.0]], "the drag, not the click after it"); + // ⌘⇧Z redoes it; a new edit after an undo drops what was undone. + press(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"undo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0]]); + ev(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 50.0, y: 40.0 }); + control(&mut app, &json!({"redo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0], [50.0, 40.0]], "nothing left to redo"); + // Undo past the first pin stops there, and the document's History is untouched. + let past = app.session.active().unwrap().history.past_len(); + for _ in 0..5 { + control(&mut app, &json!({"undo": true})).unwrap(); + } + assert!(pins(&app).is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + #[test] fn pins_drag_and_commit_through_the_engine() { let ctx = egui::Context::default(); From 2cb3b70d97fb039499db05d98ae074dad1b7e491 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:50 +0200 Subject: [PATCH 101/223] Crop tool: the drawing crosshair, not the move cursor, inside the untouched default frame (fixes #4461) (#4481) --- crates/ui-egui/src/crop_ui.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index df25c568ff..54ec3795d2 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -863,7 +863,8 @@ pub(crate) fn resize_ratio(app: &PhotocraftApp, rect: [f64; 4], mods: Modifiers) preset_ratio(app).or(if mods.shift { keep } else { None }) } -/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside. +/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside (the +/// crosshair inside the untouched default frame, where a drag draws a new one). pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { if app.ui.tool != Tool::Crop { return None; @@ -882,6 +883,8 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { 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))), + // 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, // Photoshop's curved two-headed arrow (`draw_turn_cursor`, from `turn_cursor_dir`). Hit::Outside => CursorIcon::None, @@ -1787,6 +1790,20 @@ mod tests { assert_eq!(cursor(&app, [60.0, 40.0]), None); } + /// #4461: inside the untouched default frame a drag draws a new frame, so the pointer is the + /// drawing crosshair there, not the move cursor; once the frame is changed a drag moves it. + #[test] + fn cursor_inside_the_default_frame_draws() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Crosshair)); + assert_eq!(cursor(&app, [200.0, 50.0]), Some(CursorIcon::ResizeHorizontal), "its handles still resize"); + drag(&mut app, &[[200.0, 50.0], [150.0, 50.0]], NONE); + assert!(!app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 150.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Move)); + } + /// One key press through the app's keyboard handling (`shortcuts::handle`). fn key(app: &mut PhotocraftApp, key: egui::Key, modifiers: Modifiers) { let ctx = egui::Context::default(); From a9bb38c96e766fc6462aa4891540c79df4a451cc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:56:09 +0200 Subject: [PATCH 102/223] Clone Source: clip the overlay to a shaped brush tip (#4372) (#4376) * Clone Source: clip the overlay to a shaped brush tip (#4372) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/brush_cursor.rs | 54 +++++++++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 54 ++++++++++++++++++++++++++++-- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index c53fc5de6b..49c444c154 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -73,7 +73,7 @@ pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: /// The tip outline in radius units, cached across frames; `None` when the plain circle says it all /// (a plain round tip) or the tip has no traceable shape. -fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { +pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 { return None; } @@ -88,6 +88,43 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { + let [x0, y0, x1, y1] = rect; + let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); + let mut mask = vec![false; w.saturating_mul(h)]; + let mut hits: Vec = Vec::new(); + for row in 0..h { + let y = f64::from(y0) + row as f64 + 0.5; + hits.clear(); + for ring in rings { + for (i, a) in ring.iter().enumerate() { + let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; + let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); + let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); + if (ay <= y) != (by <= y) { + hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); + } + } + } + hits.sort_by(f64::total_cmp); + for pair in hits.chunks(2) { + let [a, b] = pair else { continue }; + let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); + let start = from.ceil().max(0.0) as usize; + let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); + for x in start..end { + if let Some(m) = mask.get_mut(row * w + x) { + *m = true; + } + } + } + } + mask +} + #[cfg(test)] mod tests { use super::*; @@ -142,4 +179,19 @@ mod tests { assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); } } + + /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, + /// and a rotated flat tip leaves the corners of the brush square outside. + #[test] + fn tip_mask_follows_the_ring_shape() { + let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; + let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; + let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); + let at = |x: usize, y: usize| mask[y * 20 + x]; + assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); + assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); + assert!(!at(10, 10) && at(8, 10), "the hole is clear"); + assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); + assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0277fbdf43..d4a1cbb58b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -40,8 +40,17 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32); let Some(st) = app.session.active() else { return }; let (display, display_key) = canvas_display(app, &st.doc, output); + // A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its + // outline instead of a circle, as Photoshop does with Clipped on. + let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None }; + let tip_key = tip_rings.as_ref().map(|r| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h)); + (h.finish(), radius.to_bits(), at.map(f64::to_bits)) + }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}", + "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}", st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend ); if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { @@ -62,6 +71,14 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie *px = overlay_blend(&overlay.blend, *px, *d); } } + if let Some(rings) = &tip_rings { + let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]); + for (px, inside) in buf.px.iter_mut().zip(mask) { + if !inside { + *px = [0.0; 4]; + } + } + } let image = display_image(display.as_deref(), &buf); match app.clone_preview.as_mut() { Some(cache) => { @@ -80,7 +97,7 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32), color: tint, }; - if !overlay.clipped { + if !overlay.clipped || tip_rings.is_some() { for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] { mesh.vertices.push(vertex(f64::from(x), f64::from(y))); } @@ -6137,6 +6154,39 @@ mod tests { assert_eq!(centre("lighten"), Color32::WHITE); } + /// A shaped tip clips the Clone Source overlay to its outline, not to the brush circle. + #[test] + fn clone_overlay_is_clipped_to_a_shaped_tip() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = 20.0; + app.session.tools.brush.roundness = 0.25; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); + }); + let image = output + .textures_delta + .set + .values() + .flatten() + .find_map(|d| match &d.image { + egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), + _ => None, + }) + .expect("the overlay uploads a texture"); + output.textures_delta.clear(); + let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a(); + assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source"); + assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped"); + assert_eq!(alpha(1, 10), 255, "the tip's long axis stays"); + } + #[test] fn wayland_start_screen_hint_does_not_claim_file_drop_works() { let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/docs/scorecard.md b/docs/scorecard.md index d25198c082..712a17b611 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -137,7 +137,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | -| 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); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing | +| 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) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 3f22fa9019..c090d4f897 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -59,7 +59,7 @@ id = "TOOL-213-8" target = "Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor" status = "partial" issue = 213 -note = "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); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing" +note = "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)" [[item]] id = "TOOL-213-9" From c96842f7d94b87ba58a43f03fe94d176fe01ec57 Mon Sep 17 00:00:00 2001 From: MegaDon Date: Mon, 12 Oct 2026 21:56:14 -0400 Subject: [PATCH 103/223] The wheel steps the options bar's Mode dropdown for Brush, Pencil and Gradient (#2061) (#4447) The options bar's Mode dropdowns now use widgets::dropdown_wheel_hovered, the wheel stepping the Layers panel's Blend Mode already has (#1747): down picks the next mode, up the previous one, one per notch, stopping at the ends, over the button or the open list. The Brush and Pencil list keeps Photoshop's Clear after Dissolve. Adds my contributors/people.toml entry. Claude-Session: https://claude.ai/code/session_01RXND3Ywis6CuB5mv6fLYU7 Co-authored-by: megaDon101 <235516438+megaDon101@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 + crates/ui-egui/src/lib.rs | 3 + .../src/options_bar_mode_wheel_tests.rs | 83 +++++++++++++++++++ crates/ui-egui/src/panels.rs | 7 +- 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/options_bar_mode_wheel_tests.rs diff --git a/contributors/people.toml b/contributors/people.toml index f5b001204d..30191f52ae 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -143,3 +143,7 @@ sync_github_name = true [people.deadmeu] sync_github_name = true + +[people.megaDon101] +display_name = "megadon" +sync_github_name = false diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b6d844b80f..4a05a57282 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2401,6 +2401,9 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod options_bar_mode_wheel_tests; + #[cfg(test)] mod dropdown_press_tests; diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs new file mode 100644 index 0000000000..068561b4a0 --- /dev/null +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -0,0 +1,83 @@ +//! #2061: the scroll wheel over the options bar's Mode dropdown (Brush, Pencil, Gradient) steps +//! through the blend modes, as over the Layers panel's (#1747) and as in Photoshop. + +use egui::accesskit::Role; +use egui::{Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// The app with a new document and `tool` chosen. +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + 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()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.ui.tool = tool; + app + }); + h.run_steps(8); + h +} + +/// The options bar's Mode dropdown: the highest one showing `Normal` (the Layers panel's is lower). +fn mode_dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Normal")).map(|n| n.rect()).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap() +} + +/// `lines` wheel lines at `at` in one event: negative is down. +fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, lines: f32) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, lines), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(3); +} + +fn brush_mode(h: &Harness<'_, PhotocraftApp>) -> BlendMode { + h.state().session.tools.brush.mode +} + +#[test] +fn the_wheel_over_the_brush_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Brush); + let at = mode_dropdown(&h).center(); + assert_eq!(brush_mode(&h), BlendMode::Normal); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, -1.0); + assert!(h.state().ui.tool_options.brush_clear, "Clear follows Dissolve, as in Photoshop"); + wheel(&mut h, at, -1.0); + assert!(!h.state().ui.tool_options.brush_clear); + assert_eq!(brush_mode(&h), BlendMode::Darken); + wheel(&mut h, at, 3.0); + assert_eq!(brush_mode(&h), BlendMode::Normal, "up steps back and stops at the first mode"); +} + +#[test] +fn the_wheel_over_the_pencil_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Pencil); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, 1.0); + assert_eq!(brush_mode(&h), BlendMode::Normal); +} + +#[test] +fn the_wheel_over_the_gradient_mode_dropdown_steps_its_blend_mode() { + let mut h = harness(Tool::Gradient); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -2.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Darken); + wheel(&mut h, at, 1.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Dissolve); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1cc16a1fa3..585841042f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1226,7 +1226,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - widgets::dropdown(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + // The wheel over it steps the mode, as over the Layers panel's (#2061). + widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); opt_label(ui, tl!("Opacity")); widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, tl!("Reverse")); @@ -3576,13 +3577,13 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C /// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while -/// `~` is held (`held`). +/// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); opts.insert(at, (None, "Clear")); let mut mode = if *clear || held { None } else { Some(b.mode) }; - if widgets::dropdown(ui, id, &mut mode, &opts, 96.0) { + if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { *clear = mode.is_none(); if let Some(m) = mode { b.mode = m; From c1e3ae9f162b9b6eae37bbdbfb42cf94f3606ea4 Mon Sep 17 00:00:00 2001 From: baz <57290335+bazmarth@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:56:19 +0000 Subject: [PATCH 104/223] Pen: Auto Add/Delete anchors on the active path (#4144) (#4540) * Pen: Auto Add/Delete anchors on the active path (#4144) With the Pen's new Auto Add/Delete option (options bar, on by default as in Photoshop), a click on a segment of the active path adds an anchor there and a click on one of its anchors deletes it, one History step each, instead of starting a new path. The active path is the targeted vector mask or the active shape layer's path, then the work path. The end points of an open subpath, a path being drawn, and Shift, Alt or Cmd/Ctrl held leave the click to the Pen. Hovering highlights the segment or anchor and the cursor shows + or -, like the Add/Delete Anchor Point tools, which share the same code (anchor_tools::apply). Co-Authored-By: Claude Opus 5.5 * Pen: keep the close-drag test off Auto Add/Delete when it redraws over a finished path Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/anchor_tools.rs | 56 ++++++++++++++++- crates/ui-egui/src/anchor_tools_tests.rs | 76 ++++++++++++++++++++++++ crates/ui-egui/src/direct_select.rs | 13 ++-- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/pen_draw_tests.rs | 4 +- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/src/tool_feedback.rs | 4 ++ crates/ui-egui/src/tool_reset.rs | 3 +- crates/ui-egui/src/vector_ui.rs | 6 ++ docs/ui-parity.md | 4 +- 28 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index a314db5fc9..149792b39e 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,6 +7,9 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the +//! active path only and while no path is being drawn: a click on a segment adds, a click on an +//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -43,8 +46,42 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path } } -/// Canvas pointer events for the anchor tools; false when the current tool isn't one. +/// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of +/// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete +/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is +/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open +/// subpath are left to the Pen, as in Photoshop. +pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + return None; + } + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let (r, path, hit) = + crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let tool = match hit { + Hit::Segment(..) => Tool::AddAnchorPoint, + Hit::Anchor([s, k]) => { + let sp = path.subpaths.get(s)?; + if !sp.closed && (k == 0 || k + 1 == sp.knots.len()) { + return None; + } + Tool::DeleteAnchorPoint + } + Hit::Handle(..) => return None, + }; + Some((tool, r, path, hit)) +} + +/// Canvas pointer events for the anchor tools, and a Pen press that Auto Add/Delete takes; false +/// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + if let ToolEvent::Down { x, y, .. } = ev + && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + { + // The release finds no Pen path to finish, so the Pen draws nothing. + apply(app, r, hit); + return true; + } let Some(tool) = effective(app.ui.tool, mods) else { return false }; if let ToolEvent::Down { x, y, .. } = ev { click(app, tool, [x, y]); @@ -54,6 +91,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { let Some((r, _, hit)) = target(app, tool, p) else { return }; + apply(app, r, hit); +} + +/// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. +fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { Hit::Segment([s, k], t) => ("path.addAnchor", serde_json::json!({"subpath": s, "knot": k, "t": t})), Hit::Anchor([s, k]) => ("path.deleteAnchor", serde_json::json!({"subpath": s, "knot": k})), @@ -82,6 +124,18 @@ pub fn draw_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn( return; } let Some((_, path, hit)) = target(app, tool, p) else { return }; + draw_hit(painter, to_scr, accent, &path, hit); +} + +/// The same feedback for the Pen when Auto Add/Delete would act on a click. +pub fn draw_pen_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, mods: Modifiers) { + let Some(p) = app.hover_doc else { return }; + if let Some((_, _, path, hit)) = pen_auto(app, app.ui.tool, mods, p) { + draw_hit(painter, to_scr, accent, &path, hit); + } +} + +fn draw_hit(painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, path: &Path, hit: Hit) { match hit { Hit::Segment([s, k], t) => { let Some(seg) = path.subpaths.get(s).and_then(|sp| sp.segments().get(k).copied()) else { return }; diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index c88f1a2b19..87c045eba9 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -154,3 +154,79 @@ fn the_cursor_shows_plus_or_minus() { assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::NONE), Some(Badge::Subtract)); assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::COMMAND), None, "⌘ is Direct Selection"); } + +/// #4144: with Auto Add/Delete (on by default) a Pen click on a segment of the active path adds +/// an anchor and a click on one of its anchors deletes it, one step each, and no Pen path starts. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_active_path() { + let mut app = app(Tool::Pen); + assert!(app.ui.tool_options.pen_auto_add_delete, "on by default, as in Photoshop"); + with_path(&mut app); + let n = steps(&app); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 5); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); + assert!(app.ui.pen.is_none(), "no Pen path was started"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + let p = work(&app); + assert_eq!(p.subpaths[0].knots.len(), 4); + assert!(!p.subpaths[0].knots.iter().any(|k| k.anchor == Point::new(40.0, 120.0))); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); + assert_eq!(steps(&app), n + 2); + assert!(app.ui.pen.is_none()); + // Off the path the Pen draws. + click(&mut app, [170.0, 170.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(1)); +} + +/// Auto Add/Delete off, or ⇧ held, or a path being drawn: the Pen click places an anchor. +#[test] +fn the_pen_draws_over_the_path_with_auto_add_delete_off_shift_or_while_drawing() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let before = work(&app); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1))); + app.ui.pen = None; + + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::SHIFT); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1)), "⇧ turns it off"); + app.ui.pen = None; + + click(&mut app, [170.0, 170.0], Modifiers::NONE); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before, Some(2)), "a path being drawn takes the click"); +} + +/// The end points of an open subpath stay the Pen's; its middle anchors are deleted. +#[test] +fn the_pen_leaves_open_end_points_alone() { + let mut app = app(Tool::Pen); + let path = json!({"subpaths": [{"closed": false, "knots": [[40, 40], [120, 40], [120, 120]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + let before = work(&app); + for end in [[40.0, 40.0], [120.0, 120.0]] { + click(&mut app, end, Modifiers::NONE); + assert_eq!(work(&app), before, "{end:?}"); + app.ui.pen = None; + } + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 2); +} + +#[test] +fn the_pen_cursor_shows_what_auto_add_delete_would_do() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + with_path(&mut app); + for (at, want) in [([80.0, 120.0], Some(Badge::Add)), ([40.0, 120.0], Some(Badge::Subtract)), ([80.0, 80.0], None)] { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 120.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); + app.ui.tool_options.pen_auto_add_delete = false; + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); +} diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 01deb0a070..b18361828d 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -134,12 +134,17 @@ pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) } -/// The paths Direct Selection edits, front first: the targeted vector mask or the active shape -/// layer's path, then the work path. -pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { +/// The active paths, front first: the targeted vector mask or the active shape layer's path, then +/// the work path. The Pen's Auto Add/Delete edits these (`anchor_tools::pen_auto`). +pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let layer = crate::vector_ui::targeted_vector_mask(app).or_else(|| crate::vector_ui::active_shape_path(app)); let work = app.session.active().and_then(|st| st.doc.work_path.clone()); - let mut out: Vec<_> = layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect(); + layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect() +} + +/// The paths Direct Selection edits, front first: the active paths, then every visible shape layer. +pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { + let mut out = active_paths(app); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { if !l.visible || !(1..=path.len()).all(|n| st.doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2a9c4a9d5d..e6ebb2963c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3698,3 +3698,4 @@ 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í + Auto Add/Delete Automaticky přidat/odstranit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d209af709e..a8efbdd687 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2730,3 +2730,4 @@ 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 + Auto Add/Delete Autom. hinzuf./löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e27766fefa..ac29108ad3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3788,3 +3788,4 @@ curves Smooth Εξομάλυνση Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες + Auto Add/Delete Αυτόματη προσθήκη/διαγραφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62886211e8..0a87b52435 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2771,3 +2771,4 @@ 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 + Auto Add/Delete Añadir/eliminar automáticamente diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7789b2c02b..a8e9e2bf3b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3091,3 +3091,4 @@ 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 + Auto Add/Delete Ajout/suppr. auto diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1c9f15f0ba..0775780a4f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4010,3 +4010,4 @@ cameraRaw Yellow Sárga Load settings from a Camera Raw .xmp file Beállítások betöltése Camera Raw .xmp-fájlból Loaded Camera Raw settings from {file} Camera Raw-beállítások betöltve innen: {file} Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-beállítások betöltve innen: {file} ({n} nem támogatott) + Auto Add/Delete Automatikus hozzáadás/törlés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index cb77490c4b..f4c53e49b3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3078,3 +3078,4 @@ 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 + Auto Add/Delete Tambah/Hapus Otomatis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 288353a61b..5b12254b11 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4054,3 +4054,4 @@ 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 + Auto Add/Delete Aggiungi/elimina automaticamente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 26a31ce4de..c6a1ac7778 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3700,3 +3700,4 @@ curves Smooth スムーズ Action playback stopped アクションの再生を停止しました Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 + Auto Add/Delete 自動追加・削除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b6a95907d3..166a2c7942 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3260,3 +3260,4 @@ curves Smooth 매끄럽게 Action playback stopped 액션 재생이 중지되었습니다 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 + Auto Add/Delete 자동 추가/삭제 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 1ec6d64b0f..10ebe0fd50 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3850,3 +3850,4 @@ 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 + Auto Add/Delete Automatisch toevoegen/verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fab352b28a..4bf1a55886 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3687,3 +3687,4 @@ 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 + Auto Add/Delete Automatyczne dodawanie/usuwanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7c0def324..f562d57389 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3880,3 +3880,4 @@ 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 + Auto Add/Delete Adicionar/excluir automaticamente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2f174fd7b9..3e1fc3c399 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3035,3 +3035,4 @@ curves Smooth Сгладить Action playback stopped Воспроизведение операции остановлено Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций + Auto Add/Delete Автодобавление/удаление diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e9dc17d8d4..c67ccd0138 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3822,3 +3822,4 @@ cameraData Lens Lens Load settings from a Camera Raw .xmp file Ayarları bir Camera Raw .xmp dosyasından yükle Loaded Camera Raw settings from {file} Camera Raw ayarları {file} dosyasından yüklendi Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw ayarları {file} dosyasından yüklendi ({n} desteklenmiyor) + Auto Add/Delete Otomatik Ekle/Sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0facb9e9cc..87562df818 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3788,3 +3788,4 @@ curves Smooth Згладити Action playback stopped Відтворення операції зупинено Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій + Auto Add/Delete Автододавання/видалення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 03ad654f76..5157c3d46d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3574,3 +3574,4 @@ curves Smooth Làm mượt 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). + Auto Add/Delete Tự động thêm/xóa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 080a5ac03d..4fb92c2ebd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2695,3 +2695,4 @@ curves Smooth 平滑 Action playback stopped 动作播放已停止 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 + Auto Add/Delete 自动添加/删除 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3ed91649c2..c3010b7edf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2680,3 +2680,4 @@ curves Smooth 平滑 Action playback stopped 動作播放已停止 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 + Auto Add/Delete 自動增加/刪除 diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 229d69995a..2b3819cedd 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -139,7 +139,9 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 40.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 0.0]), "{k:?}"); assert!(k.smooth); - // A plain click on a smooth first anchor closes without touching its handles. + // A plain click on a smooth first anchor closes without touching its handles. This path is + // drawn over the anchors of the one just finished, which Auto Add/Delete would delete (#4144). + h.state_mut().ui.tool_options.pen_auto_add_delete = false; drag(&mut h, &[[20.0, 20.0], [40.0, 20.0]], None); click(&mut h, [120.0, 20.0]); click(&mut h, [120.0, 100.0]); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 620adf8d23..0e3424f25d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -588,6 +588,10 @@ pub struct ToolOptions { /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. #[serde(default = "default_curve_fit")] pub curve_fit: f32, + /// Pen › Auto Add/Delete: a Pen click on a segment of the active path adds an anchor there, + /// on one of its anchors deletes it (#4144). + #[serde(default = "yes")] + pub pen_auto_add_delete: bool, /// Marquee options bar: "normal" | "fixedRatio" | "fixedSize", with the ratio or size. #[serde(default = "default_marquee_style")] pub marquee_style: String, @@ -800,6 +804,7 @@ impl Default for ToolOptions { polygon_sides: 5, line_weight: 3.0, curve_fit: default_curve_fit(), + pen_auto_add_delete: true, marquee_style: default_marquee_style(), marquee_width: 1.0, marquee_height: 1.0, diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index bc993a7ae6..eda03b305e 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -105,6 +105,10 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option reset!(strength, sample_all_layers, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), - Tool::Pen | Tool::CurvaturePen => reset!(vector_mode), + Tool::Pen => reset!(vector_mode, pen_auto_add_delete), + Tool::CurvaturePen => reset!(vector_mode), Tool::FreeformPen => reset!(vector_mode, curve_fit), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index d4de249202..63d12fb198 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -887,6 +887,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } + // 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)); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. if let Some(pen) = &app.ui.pen { @@ -1000,6 +1002,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.stroke_width = enabled_stroke_width(o.stroke_width); } } + if tool == Tool::Pen { + crate::widgets::vline(ui, 22.0); + crate::widgets::checkbox(ui, &mut o.pen_auto_add_delete, tl!("Auto Add/Delete")); + } let enter = crate::shortcuts::pretty("Enter"); match tool { Tool::FreeformPen => { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 33fb8b319f..d48af3bfcd 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-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **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, @@ -118,6 +118,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | | 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 | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | @@ -222,3 +223,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | +| 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | From 875e17ea338abb1b31050503920d01a37e4743ea Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:56:24 -0600 Subject: [PATCH 105/223] Save As: on Windows, picking another file type changes the extension (#4394) (#4407) With File name extensions shown in Explorer, the Windows save dialog keeps a visible extension when the user picks another type: image.psd saved as PhotoCraft stayed image.psd. Given a bare name, the dialog adds the chosen type's own extension (rfd sets the default extension, which Windows updates on every type change). On Windows the dialog now starts with the stem, and the suggested type leads the type list with the suggested extension first (so .psb, .fit and .jpeg stay what they were). Names with a second dot or an upper-case extension keep the whole name. macOS and Linux are unchanged. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/photocraft/src/services.rs | 56 ++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f892de31cd..891e251bf1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -82,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { let mut v: Vec<(String, Vec)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect(); match v.iter().position(|(_, exts)| exts.contains(&ext)) { Some(i) => { - let f = v.remove(i); - v.insert(0, f); + let (name, mut exts) = v.remove(i); + // Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`. + exts.retain(|e| *e != ext); + exts.insert(0, ext); + v.insert(0, (name, exts)); } None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])), None => {} @@ -91,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { v } +/// The name the save dialog starts with. On Windows a visible extension stays put when the user +/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed +/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog +/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A +/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case +/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the +/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked. +fn dialog_file_name(suggested: &str, windows: bool) -> Option { + let path = Path::new(suggested); + let name = path.file_name()?.to_string_lossy().into_owned(); + let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) }; + let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); + Some(if bare { stem.to_string_lossy().into_owned() } else { name }) +} + /// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog /// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned /// modal window elsewhere), and a helper thread waits for it and hands the answer to the app. @@ -132,8 +150,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, for (name, exts) in save_filters(&suggested) { dialog = dialog.add_filter(name, &exts); } - if let Some(name) = Path::new(&suggested).file_name() { - dialog = dialog.set_file_name(name.to_string_lossy()); + if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) { + dialog = dialog.set_file_name(name); } if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) { dialog = dialog.set_directory(dir); @@ -669,7 +687,8 @@ mod tests { .join(" "); assert_eq!(exts, want.split(' ').collect::>()); for (name, ext) in asked.iter().zip(exts) { - assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]); + // The first type's first extension is the one Windows adds to the bare name. + assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]); } assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default"); } @@ -701,6 +720,33 @@ mod tests { assert!(err.contains("256"), "{err}"); } + /// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to + /// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked + /// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's. + #[test] + fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() { + for (suggested, name, first) in [ + ("/pics/image.psd", "image", "psd"), + ("/pics/image.psb", "image", "psb"), + ("/pics/image.pcraft", "image", "pcraft"), + ("/pics/sky.fit", "sky", "fit"), + ("cat.jpeg", "cat", "jpeg"), + ("swatches.aco", "swatches", "aco"), + ] { + assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}"); + assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}"); + // Elsewhere the dialogs keep the whole name (see `dialog_file_name`). + let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned(); + assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}"); + } + // Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no + // extension, and a dotfile. + for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] { + assert_eq!(dialog_file_name(name, true).as_deref(), Some(name)); + } + assert_eq!(dialog_file_name("", true), None); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); From b1b44595b7baac4149cb89f7f79b31d6edbf3004 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:56:29 -0600 Subject: [PATCH 106/223] Toolbar: no tooltips over an open tool flyout (#4450) (#4469) Holding a tool button to open its flyout showed that button's tooltip on top of the flyout, and other toolbar buttons kept showing theirs while it was open. The flyout is our own Area, so egui's rule that hides tooltips while a popup is open didn't apply. The toolbar's buttons (the tools, Quick Mask, Change Screen Mode) now skip their tooltips while a tool flyout is open or the mouse button is held. icons::button_with_icon_size_tip keeps the tooltip text as the button's label when the tooltip is hidden. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/icons.rs | 8 +++- crates/ui-egui/src/panels.rs | 81 ++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 390b378716..215e5994e8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -177,6 +177,12 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool /// Square icon button with an explicit glyph size, independent of its hit area. pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str) -> Response { + button_with_icon_size_tip(ui, name, box_size, icon_size, selected, tooltip, true) +} + +/// [`button_with_icon_size`] whose tooltip shows only when `show_tip`; the tooltip text still +/// names the button (for screen readers and tests) when it is hidden. +pub fn button_with_icon_size_tip(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str, show_tip: bool) -> Response { let t = Tokens::get(ui.ctx()); let style = IconButtonStyle { normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon }, @@ -190,7 +196,7 @@ pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_ let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| { paint(ui, rect, name, icon_size, tint); }); - if tooltip.is_empty() { resp } else { resp.on_hover_text(label) } + if tooltip.is_empty() || !show_tip { resp } else { resp.on_hover_text(label) } } /// Rail toggle: "on" gets a quiet filled background and full-strength icon (no accent). diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 585841042f..95bf65068f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -123,6 +123,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } + // No tool tooltips over an open flyout, or while a button is held to open one (#4450): + // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups + // doesn't hide them.) + let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down()); let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) @@ -155,7 +159,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let sel = slot.contains(&app.ui.tool); let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; - let resp = icons::button_with_icon_size(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip); + let resp = + icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); if slot.len() > 1 { paint_toolbar_flyout_indicator(ui, &t, &resp); } @@ -284,18 +289,20 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(layout, |ui| { let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button( + if icons::button_with_icon_size_tip( ui, "square-dashed", bx, + (bx * 0.52).round(), quick_mask, if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, ) .clicked() { let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); } - let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); + let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); paint_toolbar_flyout_indicator(ui, &t, &sm); // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, @@ -4752,6 +4759,74 @@ mod type_flyout_tests { assert!(menu_open(&ctx)); } + /// Every text drawn in one toolbar frame. + fn frame_texts(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); + let mut texts = Vec::new(); + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + } + + /// #4450: no tool tooltip while a flyout is open or a tool button is held to open one, for the + /// held tool or any other; a plain hover still shows its tooltip. + #[test] + fn no_tooltips_over_an_open_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let slot_of = |tool: Tool| TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap(); + let (eyedropper, brush) = (buttons[slot_of(Tool::Eyedropper)].center(), buttons[slot_of(Tool::Brush)].center()); + let tip = |tool: Tool| format!("{} ({})", tl!(tool.label()), tool.key()); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // A plain hover shows the tooltip. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(brush)]); + let mut texts = Vec::new(); + for k in 1..=10 { + texts = frame_texts(&mut app, &ctx, 1.0 + 0.1 * f64::from(k), vec![]); + } + assert!(texts.contains(&tip(Tool::Brush)), "hovered long enough: {texts:?}"); + // Held on the Eyedropper: no tooltip before or after its flyout opens. + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(eyedropper), pointer(eyedropper, true)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, 3.0 + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(Tool::Eyedropper)), "held {k}: {texts:?}"); + } + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(), "the flyout is open"); + // Released, with the flyout still open: none for the held tool or another one. + frame(&mut app, &ctx, 4.1, vec![pointer(eyedropper, false)]); + for (start, at, tool) in [(4.5, eyedropper, Tool::Eyedropper), (6.0, brush, Tool::Brush)] { + frame(&mut app, &ctx, start, vec![egui::Event::PointerMoved(at)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, start + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(tool)), "{tool:?} under the flyout: {texts:?}"); + } + } + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From 1cb3eaa5d240cafce4cbf110dc0febff385574d0 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Mon, 12 Oct 2026 20:57:47 -0500 Subject: [PATCH 107/223] Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218) Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI thread: several seconds of a frozen window with no feedback. On a 20000x16000 layer the warp alone took about 6.6 s. edit.transform is now a background job (edit_job): the warp reports progress per tile and stops when cancelled. The transform session stays open while it runs, showing the moving pixels without the box, so the layer doesn't jump back to its old place until the result lands. The modal progress dialog shows after the usual delay, and Esc or Cancel cancels the job. A copy or placed layer the session made is folded into the step, or taken back, in its own document. Undo and Redo are refused while the commit runs. Tests and scripts run inline as before. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 59 +++++++++++- crates/engine/src/jobs_tests.rs | 16 ++++ crates/engine/src/transform_cmds.rs | 138 +++++++++++++++++---------- crates/ui-egui/src/jobs_ui.rs | 2 + crates/ui-egui/src/jobs_ui_tests.rs | 62 ++++++++++++ crates/ui-egui/src/transform_tool.rs | 99 +++++++++++++++++-- 6 files changed, 317 insertions(+), 59 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 2584a877df..ba75a65336 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -9,7 +9,7 @@ use photocraft_color::PixelFormat; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde::{Deserialize, Serialize}; use crate::resample::{Resample, resize_surface}; @@ -143,14 +143,26 @@ fn catmull_rom(t: f64) -> [f64; 4] { /// The result has the same format as `src`, with alpha added if `src` had none; pixels outside /// the warped quad are transparent. pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result { - warp(src, src_rect, h, interp, None) + warp(src, src_rect, h, interp, None, &Interrupt::NONE) +} + +/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled. +pub fn warp_surface_with( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + ctl: &Interrupt, +) -> Result, photocraft_raster::AllocationError> { + let out = warp(src, src_rect, h, interp, None, ctl)?; + Ok((!ctl.cancelled()).then_some(out)) } /// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by /// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a /// layer that reaches far past the crop quad), which would make the full warped extent unbounded. pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result { - warp(src, src_rect, h, interp, Some(clip)) + warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE) } /// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every @@ -216,7 +228,15 @@ fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { Some(Rect::new(bx0, by0, bx1, by1)) } -fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option) -> Result { +/// A cancelled `ctl` skips the remaining tiles; the caller discards the result. +fn warp( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + clip: Option, + ctl: &Interrupt, +) -> Result { let mut fmt = src.format(); let converted; let mut src = if fmt.alpha { @@ -277,7 +297,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?; tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty())); let src_ref: &Surface = src; + // Tiles finished so far, for progress reported per tile (from any worker thread). + let finished = std::sync::atomic::AtomicUsize::new(0); + let total = tiles.len().max(1); let work = |t: &Rect| -> Result)>, photocraft_raster::AllocationError> { + if ctl.cancelled() { + return Ok(None); + } + let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + // The last few percent are the writes. + ctl.progress(0.98 * k as f32 / total as f32); // Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter. let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64)); let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]); @@ -413,6 +442,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt } } let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); + if ctl.cancelled() { + return Ok(out); + } for (r, v) in done { out.try_write_region(r, &v)?; } @@ -424,6 +456,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt mod tests { use super::*; + #[test] + fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]); + let r = s.content_bounds(); + let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap(); + let reported = std::sync::Mutex::new(0.0f32); + let progress = |p: f32| { + let mut m = reported.lock().unwrap(); + *m = m.max(p); + }; + let never = || false; + let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled"); + assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r)); + assert!(*reported.lock().unwrap() > 0.9); + let always = || true; + assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none()); + } + /// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate, /// self-crossing, counter-clockwise and non-finite quads have none. #[test] diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 4f631e82ef..a40b712c81 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -78,6 +78,22 @@ fn a_job_applies_as_one_undo_step_and_matches_the_inline_result() { assert!(s.jobs().is_empty()); } +#[test] +fn free_transform_runs_as_a_job_with_the_inline_result() { + let quad = json!({"quad": [[20, 10], [280, 30], [260, 190], [40, 170]]}); + let mut inline = session(300, 200); + inline.execute("edit.transform", quad.clone()).unwrap(); + + let mut s = session(300, 200); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform", quad).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(e.label, "Free Transform"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); +} + #[test] fn cancel_leaves_the_document_byte_identical() { let mut s = session(1600, 1200); diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index a09366e6fd..bcf97126cc 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,10 +1,10 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface}; +use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -304,11 +304,33 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). /// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { - check_layer_budget(doc_sel, l, h)?; - apply_transform(doc_sel, group, l, h, affine, interp) + transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE) } -fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { +/// [`transform_layer`] with progress and cancellation for the pixel warp (the slow part on a +/// large layer, #1015). +fn transform_layer_with( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + check_layer_budget(doc_sel, l, h)?; + apply_transform(doc_sel, group, l, h, affine, interp, ctl) +} + +fn apply_transform( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; let locks = transform_locks(l, group, false); if locks.position || locks.all { @@ -317,7 +339,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H if h.is_identity() { if let LayerContent::Group(g) = &mut l.content { for c in g.children.iter_mut() { - apply_transform(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } return Ok(()); @@ -328,7 +350,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - apply_transform(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } LayerContent::Text(t) => { @@ -366,12 +388,12 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H // Only the selected pixels move: lift them, clear them, warp and paste back. Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface(&lifted, src, h, interp)?; + let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface(surf, src, h, interp)?, + None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, }; } } @@ -571,49 +593,69 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); - s.edit("Free Transform", |doc, _| { - let sel = doc.selection.clone(); - // The selection outline moves too: price it before anything is built. - if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { - check_budget(sel.content_bounds(), &h, "selection")?; - } - if lone { - // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. - if !h.is_identity() { - let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; - let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); - check_budget(src, &h, "mask or channel")?; - *surf = match &sel { - Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, - None => warp_gray(surf, &h, interp)?, - }; - if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); - } - } - return Ok(()); - } - let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = group_locks(doc, id); - // Photoshop turns the Background into a normal layer before transforming it. - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?; + let p = p.clone(); + // A background job when started with `Session::start` (#1015): the warp of a large layer runs + // on a worker with progress, and can be cancelled. + crate::jobs::edit_job( + s, + "Free Transform", + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), + move |()| json!({"rect": rect}), + ) +} + +/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +#[allow(clippy::too_many_arguments)] +fn transform_doc( + doc: &mut Document, + id: Option, + p: &Value, + lone: bool, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + let sel = doc.selection.clone(); + // The selection outline moves too: price it before anything is built. + if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { + check_budget(sel.content_bounds(), h, "selection")?; + } + if lone { + // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. if !h.is_identity() { - // Type layers re-render from their new transform. - let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - refresh_text(&snapshot, l); - } - // The selection outline moves with the pixels. + let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); + check_budget(src, h, "mask or channel")?; + *surf = match &sel { + Some(sel) => warp_gray_selected(surf, sel, h, interp)?, + None => warp_gray(surf, h, interp)?, + }; if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } } - Ok(()) - })?; - Ok(json!({"rect": rect})) + return Ok(()); + } + let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let is_group = doc.layer(id).is_some_and(Layer::is_group); + let group = group_locks(doc, id); + // Photoshop turns the Background into a normal layer before transforming it. + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?; + if !h.is_identity() { + // Type layers re-render from their new transform. + let snapshot = doc.clone(); + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } + // The selection outline moves with the pixels. + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + } + } + Ok(()) } pub fn specs() -> Vec { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 5dc1cba41d..83218402d6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -449,6 +449,8 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { true } }); + // A Free Transform commit's session ends with its job (then reported like any job). + crate::transform_tool::on_job_event(app, &e); if crate::prefs_ui::on_recovery_event(app, &e) { return; } diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index bc29064512..3e2750f481 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -704,3 +704,65 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { } } } + +fn layer_count(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +/// Free Transform the active layer to the right half of the canvas, then ↩. +fn transform_and_commit(h: &mut Harness<'static, PhotocraftApp>, copy: bool) { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + if copy { crate::transform_tool::begin_copy(app, &ctx) } else { crate::transform_tool::begin(app, &ctx) }.unwrap(); + app.ui.transform.as_mut().unwrap().quad = [[160.0, 0.0], [320.0, 0.0], [320.0, 120.0], [160.0, 120.0]]; + crate::transform_tool::commit(app); +} + +#[test] +fn a_free_transform_commit_runs_in_the_background_and_keeps_its_preview() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + transform_and_commit(&mut h, true); + let app = h.state(); + // The warp runs as a job; the moving pixels stay on screen until it lands, and the box's + // history is closed meanwhile. + let job = crate::transform_tool::committing(app).expect("the commit runs as a job"); + assert!(app.session.job(job).is_some_and(|j| j.label == "Free Transform")); + assert!(app.ui.transform.is_some() && app.transform_preview.is_some()); + assert_eq!(crate::transform_tool::is_enabled(app, "edit.undo"), Some(false)); + assert!(h.state_mut().run("edit.undo", json!({})).is_err()); + crate::transform_tool::commit(h.state_mut()); + let t = Instant::now(); + while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) { + h.step(); + } + h.run_steps(2); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job"); + // The copy and its transform are one step, as when the commit ran inline. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers + 1, steps + 1)); + assert_eq!(st.history.undo_label(), Some("Free Transform")); + let moved = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!((moved.x0, moved.y0, moved.x1, moved.y1), (160, 0, 320, 120)); +} + +#[test] +fn esc_cancels_a_background_free_transform_commit() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + let before = h.state().session.active().unwrap().doc.clone(); + transform_and_commit(&mut h, true); + let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job"); + // Esc reaches the transform tool first (shortcuts), which cancels the job. + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(3); + let app = h.state(); + assert!(app.session.job(job).is_none()); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + // The copy is taken back with the transform, leaving nothing to redo. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers, steps)); + assert!(!st.history.can_redo()); + assert_eq!(st.doc.layers.len(), before.layers.len()); +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 0af28a81c5..b2ff21d531 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2}; use photocraft_algo::transform::Homography; use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use serde_json::json; @@ -85,6 +86,9 @@ pub struct TransformPreview { /// A layer's session keeps `doc` and `opacity` up to date with edits made while the box is /// open ([`follow_edits`]); `None` for a lone mask or channel and Transform Selection. follow: Option, + /// The commit running as a background job (#1015): the moving pixels stay on screen, without + /// the box, until it lands; Esc cancels it. The session ends with the job. + committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, } @@ -229,6 +233,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), follow: Some(Follow { revision, hidden: lifted.is_none() }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -327,6 +332,7 @@ pub(crate) fn begin_content_aware( opacity: layer.opacity * layer.fill_opacity, above: Some(above), follow: Some(Follow { revision, hidden: false }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -385,13 +391,20 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) { /// After the transform: the step that made the layer and the transform become one history step, /// the transform's for a copy and Place Embedded for a placed file. -fn fold_made(app: &mut PhotocraftApp, made: MadeLayer) { +fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { + // A background commit lands in its own document, which need not be the active one by then. + let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return }; + let active = app.session.active_index(); + app.session.set_active(index); if let Some(st) = app.session.active_mut() { st.history.purge_last(); if made == MadeLayer::Place { st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED); } } + if let Some(active) = active { + app.session.set_active(active); + } } /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the @@ -440,6 +453,7 @@ fn begin_lone( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -501,6 +515,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: content_aware: None, above: None, follow: None, + committing: None, }); app.ui.transform = Some(TransformSession { session, @@ -574,6 +589,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -949,7 +965,13 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } +/// Undo and Redo while the commit runs in the background. +const STILL_APPLYING: &str = "Free Transform is still being applied"; + pub fn commit(app: &mut PhotocraftApp) { + if committing(app).is_some() { + return; + } let document = app.transform_preview.as_ref().map(|pv| pv.doc.id); if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) { cancel(app); @@ -957,7 +979,7 @@ pub fn commit(app: &mut PhotocraftApp) { } let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; - let preview = app.transform_preview.take(); + let mut preview = app.transform_preview.take(); if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -1012,16 +1034,54 @@ pub fn commit(app: &mut PhotocraftApp) { } app.run("edit.transform", p) }; + // A large layer's warp runs in the background: the session stays until the job ends. + if let Ok(v) = &r + && let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64) + && let Some(pv) = preview.as_mut() + { + pv.committing = Some(JobId(job)); + app.ui.transform = Some(t); + app.transform_preview = preview; + return; + } match r { - Ok(_) => { - if let Some(made) = made { - fold_made(app, made); - } - } + Ok(_) => finish(app, document, LayerId(t.layer), made, true), Err(e) => keep_open(app, t, preview, e), } } +/// After the transform landed or not: fold the layer the session made into its step, or take it +/// back. +fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made: Option, landed: bool) { + let (Some(made), Some(document)) = (made, document) else { return }; + if landed { + fold_made(app, document, made); + } else { + take_back_made(app, document, layer); + } +} + +/// The job applying the transform, while it runs. +pub(crate) fn committing(app: &PhotocraftApp) -> Option { + app.transform_preview.as_ref().and_then(|pv| pv.committing) +} + +/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its +/// status and errors are reported like any job's. +pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { + if committing(app) != Some(e.id) { + return; + } + let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; + // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. + if let JobOutcome::Failed(msg) = &e.outcome { + pv.committing = None; + keep_open(app, t, Some(pv), msg.clone()); + return; + } + finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_))); +} + /// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it /// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or /// a placed layer it made goes only when it is cancelled. @@ -1038,6 +1098,9 @@ fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option Option { let t = app.ui.transform.as_ref()?; + if committing(app).is_some() { + return box_command(id).then_some(false); + } let steps = app.transform_preview.as_ref().map(|pv| &pv.steps); match id { // Content-Aware Scale's box only scales: no switching it to another transform or Warp. @@ -1168,8 +1239,16 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as /// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar. /// `None` for other commands or when not transforming. +/// The commands the open box answers itself ([`is_enabled`], [`intercept`]). +fn box_command(id: &str) -> bool { + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() +} + pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { let warping = app.ui.transform.as_ref()?.warp.is_some(); + if committing(app).is_some() { + return box_command(id).then(|| Err(STILL_APPLYING.into())); + } match id { "edit.undo" => Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), @@ -1259,6 +1338,9 @@ fn accepts_outside_click(g: Gesture, end: [f64; 2], tol: f64) -> bool { /// Pointer input while transforming. Returns false when no transform is active. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let Some(t) = app.ui.transform.clone() else { return false }; + if committing(app).is_some() { + return true; + } let tol = handle_tolerance(app); if t.warp.is_some() { warp_pointer(app, ev, tol, mods); @@ -1991,6 +2073,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(a) = &pv.above { a.draw(painter, xf); } + if pv.committing.is_some() { + return; + } let accent = crate::theme::Tokens::get(painter.ctx()).accent; if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) { crate::vector_ui::draw_outline( From 4d4103460f2f606ebd10dc17cd9c6ce77d452164 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 20:01:17 -0600 Subject: [PATCH 108/223] Toolbar: a press outside a tool flyout closes it (#4460) (#4480) An open tool flyout closed only on a click outside it. Dragging on the canvas (cropping, painting) isn't a click, so the flyout stayed open over the work. Any press that starts outside the flyout and its button now closes it, as in Photoshop. A press on the flyout itself still keeps it open for the row it picks on release, and Escape still closes it. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels.rs | 58 +++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 95bf65068f..6134274d4a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -264,10 +264,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }, ); - let held_release = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) == Some(key); - let clicked_outside = - ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered(); - if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Any press outside the flyout and its button closes it, as in Photoshop: a + // drag on the canvas (cropping, painting) isn't a click (#4460). + let pressed_outside = ui.input(|i| { + i.pointer.any_pressed() + && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p)) + }); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } @@ -4827,6 +4830,53 @@ mod type_flyout_tests { } } + /// #4460: a press outside an open flyout closes it, even when it becomes a drag (cropping or + /// painting on the canvas) rather than a click; a press on the flyout keeps it for its pick. + #[test] + fn a_drag_outside_closes_the_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(); + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + let primary = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let open = |app: &mut PhotocraftApp, t: f64| { + frame(app, &ctx, t, vec![egui::Event::PointerMoved(at), secondary(true)]); + frame(app, &ctx, t + 0.05, vec![secondary(false)]); + frame(app, &ctx, t + 0.1, vec![]); + assert!(flyout(&ctx), "right-click opens it"); + }; + // A press on the flyout itself keeps it open (the row picks on release). + open(&mut app, 1.0); + let menu = ctx.memory(|m| m.area_rect(egui::Id::new(("tool-slot", index)).with("flyout"))).unwrap(); + frame(&mut app, &ctx, 1.5, vec![egui::Event::PointerMoved(menu.center()), primary(menu.center(), true)]); + assert!(flyout(&ctx), "a press on the flyout keeps it"); + frame(&mut app, &ctx, 1.55, vec![primary(menu.center(), false)]); + // A drag far from the toolbar (the canvas) closes it as soon as it starts. + open(&mut app, 2.0); + let canvas = egui::pos2(900.0, 900.0); + frame(&mut app, &ctx, 2.5, vec![egui::Event::PointerMoved(canvas), primary(canvas, true)]); + frame(&mut app, &ctx, 2.55, vec![egui::Event::PointerMoved(canvas + vec2(80.0, 60.0))]); + assert!(!flyout(&ctx), "a press outside closes it, even one that becomes a drag"); + frame(&mut app, &ctx, 2.6, vec![primary(canvas + vec2(80.0, 60.0), false)]); + assert!(!flyout(&ctx)); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From cee010ef5a60e392dddc611e7312c35871b36df5 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:03:15 +0200 Subject: [PATCH 109/223] Replace Color: a canvas eyedropper picks the From colour (#4143) (#4247) The dialog had no colour control at all: the engine defaulted the From colour to the foreground and the spec's `color` param rendered nothing. It now shows a Color row (swatch opens the Color Picker, seeded from the foreground) plus a pipette that arms a canvas eyedropper: the next click on the image samples the merged composite into the field, like the Curves picker and Photoshop's Replace Color. Co-authored-by: highperfocused Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/adjust_cmds.rs | 2 +- crates/ui-egui/src/canvas.rs | 9 ++- crates/ui-egui/src/filter_dialog.rs | 90 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + 6 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 15c3af2060..738ef921f4 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -454,7 +454,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"color":json,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, + r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, has_pixels, replace_color ), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d4a1cbb58b..4f78b853f2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3022,10 +3022,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if under_dialog { // With a colour dialog picker armed the image is its eyedropper, whatever the tool; Space - // and the middle button still pan. Curves uses the same merged-composite sampler as the - // Color Picker, never the reduced adjustment preview. + // and the middle button still pan. Curves and Replace Color use the same merged-composite + // sampler as the Color Picker, never the reduced adjustment preview. let curves_picking = crate::adjust_dialog::picker_armed(app); - let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking); + let replace_picking = crate::filter_dialog::color_eyedropper_armed(app); + let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking || replace_picking); let range_picking = primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling); // Select and Mask's Refine Edge Brush paints on the image. @@ -3049,6 +3050,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let d = xf.to_doc(p); if curves_picking { crate::adjust_dialog::sample_at(app, d[0], d[1]); + } else if replace_picking { + crate::filter_dialog::color_eyedropper_sample(app, d[0], d[1]); } else { crate::color_picker_ui::sample_at(app, d[0], d[1]); } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 8f588fbc99..31ef157110 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -343,6 +343,11 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option // Identity kernel: 1 in the centre. Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)), + // Replace Color starts from the foreground colour and can sample a new one off the canvas. + Kind::Color(None) if command == REPLACE_COLOR => { + let fg = app.session.tools.foreground; + json!(crate::color_picker_ui::hex([fg[0], fg[1], fg[2]])) + } Kind::Color(None) => return None, // Colour inputs come from the current swatches, so the proxy preview matches the result. Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground), @@ -542,6 +547,38 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { key == "angle" && ANGLE_DIAL.contains(&command) } +/// Replace Color (#4143): the dialog's "From" colour, sampled from the image with a canvas +/// eyedropper like Photoshop's. While armed, the next click on the image writes the merged +/// composite colour into the `color` field (the same sampler the Color Picker uses). +const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +const COLOR_EYEDROPPER: &str = "__colorEyedropper"; + +fn color_eyedropper_owns(f: &Map) -> bool { + f.get("__filter").and_then(Value::as_bool) == Some(true) + && f.get("__command").and_then(Value::as_str) == Some(REPLACE_COLOR) + && f.get(COLOR_EYEDROPPER).and_then(Value::as_bool) == Some(true) +} + +/// Whether the top dialog is a Replace Color with its canvas eyedropper armed. +pub(crate) fn color_eyedropper_armed(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| color_eyedropper_owns(&d.fields)) +} + +/// Sample the merged image at document point (x, y) into the armed dialog's "From" colour. +/// Returns true when the click was consumed, even off-image or over transparency, where nothing +/// changes and the eyedropper stays armed (like the Curves picker). +pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { + let Some(id) = app.ui.dialogs.last().filter(|d| color_eyedropper_owns(&d.fields)).map(|d| d.id) else { + return false; + }; + if let Some(rgb) = crate::canvas::composite_color(app, x, y) + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + } + true +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -684,11 +721,18 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let bytes = rgb.map(q); let name = label(&p.key); let mut clicked = false; + let eyedropper = cmd == REPLACE_COLOR && p.key == "color"; ui.horizontal(|ui| { ui.label(egui::RichText::new(&name).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // PhotoCraft's Color Picker, as everywhere else (#2144). clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), &name).clicked(); + if eyedropper { + let armed = f.get(COLOR_EYEDROPPER).and_then(Value::as_bool).unwrap_or(false); + if crate::icons::button(ui, "pipette", 24.0, armed, "Pick Color From Image").clicked() { + f.insert(COLOR_EYEDROPPER.into(), json!(!armed)); + } + } ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); }); }); @@ -1911,4 +1955,50 @@ mod tests { let fields = &app.ui.dialogs.last().unwrap().fields; assert_eq!(fields["direction"], json!("cw"), "Explicit default matching first option is preserved"); } + + /// #4143: Replace Color shows the From colour (defaulting to the foreground) and its canvas + /// eyedropper samples the merged image into the dialog. + #[test] + fn replace_color_eyedropper_samples_the_image() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + let id = open(&mut app, REPLACE_COLOR).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#ff0000"), "the dialog starts from the foreground colour"); + assert!(!color_eyedropper_armed(&app)); + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_armed(&app)); + assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. + assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + assert!(color_eyedropper_armed(&app)); + // Disarmed, the dialog never claims the click. + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(false)); + assert!(!color_eyedropper_armed(&app)); + assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0)); + } + + /// #4143: the eyedropper button arms and disarms the canvas picker. + #[test] + fn replace_color_dialog_arms_the_eyedropper_button() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(color_eyedropper_armed(h.state())); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(!color_eyedropper_armed(h.state())); + } } diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0775780a4f..9476ed77a2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3396,6 +3396,7 @@ cameraRaw Yellow Sárga Photomerge Fotóegyesítés Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz Pick screen color Szín kiválasztása a képernyőről + Pick Color From Image Szín felvétele a képről Pins Tűk Pixel Layer Pixelréteg Place Scale Marker Méretarány-jelölő elhelyezése diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c67ccd0138..c34e673367 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2316,6 +2316,7 @@ Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) Stroke color Kontur rengi Pick screen color Ekran rengini seç + Pick Color From Image Görüntüden Renk Seç Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5157c3d46d..9fc122a766 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2033,6 +2033,7 @@ layers Fill: Độ tô: Option-click to define a source point to clone from Option+nhấp để chỉ định điểm nguồn nhân bản Out of gamut for printing: click for the nearest printable colour Ngoài gamut in: nhấp để lấy màu in được gần nhất Pick screen color Lấy màu màn hình + Pick Color From Image Lấy màu từ hình ảnh Point Sample Mẫu điểm Pupil Size Kích thước đồng tử Quick Selection found nothing there Chọn nhanh không tìm thấy gì From 575f8eb2bc54eeeeba27c4c5349f2b7002deb68b Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:16:06 +0300 Subject: [PATCH 110/223] Allow toolbar tool shortcuts to be remapped (#4194) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 8 ++ crates/ui-egui/src/panels.rs | 10 +- crates/ui-egui/src/prefs_ui.rs | 8 ++ crates/ui-egui/src/shortcut_capture_tests.rs | 88 ++++++++++++++++++ crates/ui-egui/src/shortcut_dispatch.rs | 1 + crates/ui-egui/src/shortcuts.rs | 8 +- crates/ui-egui/src/tool_shortcuts.rs | 96 ++++++++++++++++++++ docs/gaps.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tool_shortcuts.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a05a57282..873437b260 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -199,6 +199,7 @@ mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; +mod tool_shortcuts; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 161c64c0c2..71fab42239 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -161,6 +161,10 @@ 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; + return Ok(json!({"tool": tool})); + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::begin(app).map(|_| Value::Null); } @@ -660,6 +664,9 @@ fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { } pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + if crate::tool_shortcuts::tool(id).is_some() { + return true; + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::can_transform(app); } @@ -890,6 +897,7 @@ pub struct MenuItem { pub fn is_live(id: &str) -> bool { static UI_IDS: std::sync::OnceLock> = std::sync::OnceLock::new(); photocraft_engine::commands::find(id).is_some() + || crate::tool_shortcuts::tool(id).is_some() || UI_IDS.get_or_init(|| UI_COMMANDS.iter().map(|c| c.0).collect()).contains(id) || panel_alias(id).is_some() || workspace_name(id).is_some() diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6134274d4a..78f1715c98 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -157,8 +157,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let key = egui::Id::new(("tool-slot", *slot_index)); let tool = slot_tool(ui, app.ui.tool, slot, key); let sel = slot.contains(&app.ui.tool); - let tip = - if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; + let tip = match crate::tool_shortcuts::shortcut(app, tool) { + Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), + None => tl!(tool.label()).to_string(), + }; let resp = icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); if slot.len() > 1 { @@ -247,11 +249,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::FontId::proportional(12.5), t.text, ); - if item.key() != '\0' { + if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) { ui.painter().text( pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, - item.key().to_string(), + crate::shortcuts::pretty(&sc), egui::FontId::proportional(12.0), t.text_dim, ); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 202944b58a..8260fed722 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -765,6 +765,11 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, let at = out.iter().rposition(|i| i.2.first().map(String::as_str) == Some("Layer")).map_or(out.len(), |i| i + 1); out.splice(at..at, layer); out.extend(tools); + out.extend( + crate::state::Tool::ALL + .into_iter() + .map(|tool| (crate::tool_shortcuts::id(tool), tool.label().to_string(), vec!["Tools".into()], crate::tool_shortcuts::default(tool))), + ); for (id, label, def) in prefs::TEMPORARY_TOOLS { if seen.insert(id.to_string()) { out.push((id.to_string(), label.to_string(), vec!["Tools".into(), "Temporary".into()], Some(def.to_string()))); @@ -1670,6 +1675,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64, name: &str) -> Value { h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null) } +#[test] +fn toolbar_shortcut_can_be_captured_and_restored_on_restart() { + let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "B").click(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["tools.select.pencil"], json!("F6")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Pencil); + + let saved = h.state().session.prefs_to_json(); + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(&saved).unwrap(); + let mut restarted = PhotocraftApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![Event::Key { key: Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut restarted, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(restarted.ui.tool, crate::state::Tool::Pencil); +} + +#[test] +fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key, modifiers| { + ctx.begin_pass(egui::RawInput { + // A tap: press and release, so the next press isn't a held key's repeat. + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + app.run("edit.keyboardShortcuts", json!({"set": {"tools.select.move": "F6", "tools.select.pencil": ""}, "allowUnknown": true})).unwrap(); + app.ui.tool = Tool::Zoom; + press(&mut app, Key::V, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Artboard, "the old key only selects unchanged tools"); + press(&mut app, Key::F6, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Move); + + app.ui.tool = Tool::Pencil; + press(&mut app, Key::V, Modifiers::NONE); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory"); + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool"); + + app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + app.ui.tool = Tool::Brush; + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group"); + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference"); +} + +#[test] +fn toolbar_shortcuts_participate_in_conflict_resolution() { + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + capture(&mut h, &crate::shortcuts::pretty("Cmd+F"), Key::ControlLeft, Modifiers::COMMAND, Key::B); + // Remove Ctrl from the captured binding: a plain B belongs to the Brush group by default. + h.state_mut().ui.dialog_mut(id).unwrap().fields["overrides"]["edit.search"] = json!("B"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + for tool in ["brush", "pencil", "mixerBrush", "colorReplacement"] { + assert_eq!(h.state().session.prefs().shortcuts.get(&format!("tools.select.{tool}")).map(String::as_str), Some("")); + } + let ctx = h.ctx.clone(); + h.state_mut().ui.tool = crate::state::Tool::Move; + key(&mut h, Key::B, true, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().ui.palette_open, "B now runs Search"); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move); + assert_eq!(crate::shortcut_dispatch::take_log(&ctx).last().map(|i| i.0.as_str()), Some("edit.search")); +} + /// Click the selected command's shortcut button, then press `modifier` and `k` like a keyboard: /// the modifier key goes down first (its own key event), then the key with the modifier held. fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held: Modifiers, k: Key) { diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 17607c479f..1a6980077d 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -302,6 +302,7 @@ fn label(id: &str) -> String { .map(|c| c.1) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) .or_else(|| photocraft_engine::commands::find(id).map(|c| c.label)) + .or_else(|| crate::tool_shortcuts::tool(id).map(|tool| tool.label())) .unwrap_or(id) .trim_end_matches('…') .to_string() diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6e592385a1..c8528e1aa5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -108,6 +108,7 @@ pub fn default_shortcut(id: &str) -> Option { .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).and_then(|c| c.2)) .or_else(|| photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)) .map(str::to_string) + .or_else(|| crate::tool_shortcuts::tool(id).and_then(crate::tool_shortcuts::default)) } /// With ⇧ held the OS reports the shifted character as the logical key (US layout): ⇧; is `:`. @@ -580,15 +581,18 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key)))); let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { + if !crate::tool_shortcuts::uses_default(app, t) { + continue; + } let Some(k) = crate::spring_tools::key_of(t.key()) else { continue }; let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { let before = app.ui.tool; - let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect(); + let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).unwrap_or(group[0]), + None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), }; // `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)); diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs new file mode 100644 index 0000000000..345b7f57b3 --- /dev/null +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -0,0 +1,96 @@ +//! Toolbar tools in Edit › Keyboard Shortcuts (#4086). Explicit assignments select an +//! individual tool; unchanged tools keep Photoshop's shared keys and Shift cycling. + +use crate::{PhotocraftApp, state::Tool}; + +pub(crate) fn id(tool: Tool) -> String { + let mut name = format!("{tool:?}"); + if let Some(first) = name.get_mut(..1) { + first.make_ascii_lowercase(); + } + format!("tools.select.{name}") +} + +pub(crate) fn tool(id: &str) -> Option { + let name = id.strip_prefix("tools.select.")?; + let tool = Tool::from_name(name)?; + (id == self::id(tool)).then_some(tool) +} + +pub(crate) fn default(tool: Tool) -> Option { + (tool.key() != '\0').then(|| tool.key().to_string()) +} + +pub(crate) fn shortcut(app: &PhotocraftApp, tool: Tool) -> Option { + crate::shortcuts::effective_shortcut(app, &id(tool), default(tool).as_deref()) +} + +pub(crate) fn uses_default(app: &PhotocraftApp, tool: Tool) -> bool { + !app.session.prefs().shortcuts.contains_key(&id(tool)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_toolbar_tool_has_a_unique_live_shortcut_row() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let items = crate::prefs_ui::shortcut_items(&app); + let mut ids = std::collections::HashSet::new(); + for t in Tool::ALL { + let id = id(t); + assert!(ids.insert(id.clone())); + assert_eq!(tool(&id), Some(t)); + assert!(crate::menus::is_live(&id)); + assert!(crate::menus::is_enabled(&app, &id)); + let rows: Vec<_> = items.iter().filter(|i| i.0 == id).collect(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].1, t.label()); + assert_eq!(rows[0].2, ["Tools"]); + assert_eq!(rows[0].3, default(t)); + assert_eq!(crate::shortcuts::default_shortcut(&id), default(t)); + crate::menus::invoke(&mut app, &ctx, &id, json!({})).unwrap(); + assert_eq!(app.ui.tool, t); + } + for id in ["tools.select.", "tools.select.missing", "tools.select.Brush"] { + assert!(!crate::menus::is_live(id)); + assert!(crate::menus::invoke(&mut app, &ctx, id, json!({})).is_err()); + } + } + + #[test] + fn toolbar_hints_follow_assignments_removal_and_reset() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + assert_eq!(shortcut(&app, Tool::SingleRowMarquee), None); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "F6"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("F6")); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": ""})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil), None); + app.run("prefs.reset", json!({"path": "shortcuts.tools.select.pencil"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + } + + #[test] + fn a_remapped_tool_letter_does_not_steal_text_input() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "Q"})).unwrap(); + let mut h = egui_kittest::Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.text_edit_singleline(&mut app.ui.status).request_focus(); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + assert!(h.ctx.text_edit_focused()); + h.key_press(egui::Key::Q); + h.event(egui::Event::Text("q".into())); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.status, "q"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 8e75167810..70b2dde405 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-12 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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 @@ -170,6 +170,8 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), + with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard @@ -240,6 +242,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G9: toolbar tool shortcut editing (#4086); the remaining workspace gaps stay open | | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d48af3bfcd..8c0e0d2580 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-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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, @@ -183,6 +183,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | | `.kys` import | done (`engine/src/kys.rs`) | | | Shortcut glyphs per platform | done (UI-217-19) | | +| Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | 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) | | @@ -224,3 +225,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 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 | From f80e57fb8a2e0c24b7a65f4b3787399701d999fc Mon Sep 17 00:00:00 2001 From: Minischatten <61423557+Minischatten@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:16:46 +0200 Subject: [PATCH 111/223] Fix macOS system dialog localization (#4261) * Fix macOS system dialog localization * Info.plist: declare the Hungarian and Turkish localizations too Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- packaging/macos/Info.plist.in | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 5dec67ee2e..3a190a4b17 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,31 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + ja + zh-Hans + zh-Hant + es + ru + uk + cs + fr + id + ko + pl + de + pt-BR + el + nl + it + hu + tr + vi + CFBundleDisplayName PhotoCraft CFBundleName From ee8aca562b4bd4af7afd42d19c5c48c7e1f71573 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:23:46 +0200 Subject: [PATCH 112/223] Engine: reject unknown params for layer.hideLayers/showLayers via an opt-in registry check (#4336) (#4364) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/param_check.rs | 138 +++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + 4 files changed, 141 insertions(+) create mode 100644 crates/engine/src/param_check.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 42c7fa4862..9c8cb9cdd0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -632,6 +632,7 @@ impl Session { if id == "file.new" { crate::document_preset_cmds::validate_new_params(self, ¶ms)?; } + crate::param_check::check(spec, ¶ms)?; crate::filters::validate_params(id, ¶ms)?; // A floating selection drops before any other command (Undo puts it back instead). if let Some(v) = crate::float_cmds::before_command(self, id)? { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1cc8eb692b..f1bd9bd16e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -76,6 +76,7 @@ pub mod multichannel_cmds; pub mod new_layer; pub mod notes_cmds; pub mod paint_cmds; +mod param_check; mod path_edit_cmds; pub mod pattern_cmds; pub mod pattern_fill_cmds; diff --git a/crates/engine/src/param_check.rs b/crates/engine/src/param_check.rs new file mode 100644 index 0000000000..a59236d1d1 --- /dev/null +++ b/crates/engine/src/param_check.rs @@ -0,0 +1,138 @@ +//! Reject params a command does not document (#4336). +//! +//! Commands read the keys they know from a JSON object and used to ignore the rest, so a +//! misspelled key (`{"ids":[4]}` for `{"layer":4}`) ran the command on its defaults and reported +//! success. For the commands in [`STRICT`] the dispatcher now checks the keys against the +//! command's `params` doc once, for every transport (CLI, MCP, control channel, UI, actions). +//! +//! This is opt-in because `params` docs are free-form text and many are not complete yet (the +//! engine's own tests pass undocumented keys to `paint.pencil`, `timeline.create`, +//! `layer.new.artboard`, the Selective Color and Color Lookup adjustments and more). To opt a +//! command in: make sure its `params` doc names every key the command reads as `"key":...`, and +//! add its id to [`STRICT`]; `strict_commands_have_closed_docs` checks the doc is parseable. +//! A doc with a top-level `…` is open-ended and is never checked. + +use serde_json::Value; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result}; + +/// Keys any command accepts: the history-coalescing key every command honours (see +/// `Session::execute`) and the Channels-panel `"target"` that pixel commands follow. +const UNIVERSAL: &[&str] = &["coalesce", "target"]; + +/// The commands whose unknown params are rejected: their `params` doc is complete. +const STRICT: &[&str] = &["layer.hideLayers", "layer.showLayers"]; + +/// The keys a `params` doc names, or `None` when the doc is open-ended. +fn documented_keys(doc: &str) -> Option> { + let bytes = doc.as_bytes(); + let (mut depth, mut in_str, mut start) = (0usize, false, 0usize); + let mut quoted: Vec<(usize, usize)> = Vec::new(); + for (i, &b) in bytes.iter().enumerate() { + if in_str { + if b == b'"' { + in_str = false; + quoted.push((start, i)); + } + continue; + } + match b { + b'"' => { + in_str = true; + start = i + 1; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + // `…` / `...` straight inside the top-level braces: the list is not complete. + if depth == 1 + && (doc.get(i..).is_some_and(|r| r.starts_with('…') || r.starts_with("..."))) + && doc.get(..i).is_some_and(|l| l.trim_start().starts_with('{')) + { + return None; + } + } + let mut keys = Vec::new(); + for (n, &(a, b)) in quoted.iter().enumerate() { + let Some(word) = doc.get(a..b) else { continue }; + if word.is_empty() || !word.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') { + continue; + } + // A key is followed by `:`, or by `,"other"` pairs that end in one (`"all","pixels":bool`). + let mut end = b + 1; + let mut k = n; + loop { + let rest = doc.get(end..).unwrap_or(""); + if rest.starts_with(':') { + keys.push(word); + break; + } + let Some(after) = rest.strip_prefix(',') else { break }; + let Some(&(na, nb)) = quoted.get(k + 1) else { break }; + if na != end + 2 || after.as_bytes().first() != Some(&b'"') { + break; + } + end = nb + 1; + k += 1; + } + } + Some(keys) +} + +/// Err when `params` has a key the command does not document (only for commands in [`STRICT`]). +pub(crate) fn check(spec: &CommandSpec, params: &Value) -> Result<()> { + if !STRICT.contains(&spec.id) { + return Ok(()); + } + let Some(map) = params.as_object() else { return Ok(()) }; + let Some(keys) = documented_keys(spec.params) else { return Ok(()) }; + for key in map.keys() { + if UNIVERSAL.contains(&key.as_str()) || keys.contains(&key.as_str()) { + continue; + } + let expected = if keys.is_empty() { "no parameters".to_string() } else { format!("expected: {}", keys.join(", ")) }; + return Err(EngineError::BadParams { cmd: spec.id.to_string(), msg: format!("unknown parameter `{key}` ({expected})") }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn documented_keys_reads_keys_and_stops_at_open_docs() { + assert_eq!(documented_keys(r##"{"layer":id?} (shows them instead)"##), Some(vec!["layer"])); + assert_eq!(documented_keys(r##"{"locks":{"all","pixels":bool}?,"name":str?}"##), Some(vec!["locks", "all", "pixels", "name"])); + assert_eq!(documented_keys("{}"), Some(vec![])); + assert_eq!(documented_keys(r##"{"layer":id?, …params of that kind}"##), None); + } + + #[test] + fn strict_commands_have_closed_docs() { + for id in STRICT { + let spec = crate::commands::find(id).expect("strict command exists"); + assert!(documented_keys(spec.params).is_some_and(|k| !k.is_empty()), "{id}: params doc must list its keys"); + } + } + + #[test] + fn unknown_param_is_an_error_naming_the_key_and_the_accepted_ones() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let visible = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(a)).unwrap().visible; + for cmd in ["layer.hideLayers", "layer.showLayers"] { + let err = s.execute(cmd, json!({"ids": [a]})).unwrap_err().to_string(); + assert!(err.contains("unknown parameter `ids`") && err.contains("expected: layer"), "{err}"); + assert!(visible(&s), "{cmd}: nothing ran"); + } + s.execute("layer.hideLayers", json!({"layer": a, "coalesce": "k"})).unwrap(); + assert!(!visible(&s)); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 166a61210c..e571e94f8b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -75,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `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.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) - `ui.resize {width, height}`: resize the main window - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback From 4543ef35b975b2c177ff55b3acd5d92fea12387c Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 10:23:50 +0800 Subject: [PATCH 113/223] Preserve signed HDR colors in float text rasterization (#4379) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/text/src/render.rs | 7 +++++-- crates/text/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 44165461aa..b50a63fe0a 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -1,6 +1,6 @@ //! Rasterizes a [`TextLayout`] into a document-space [`Surface`] of any pixel format. -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::text::AntiAlias; use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; @@ -267,7 +267,10 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo let a = px[n]; if a > 0.0 { for v in &mut px[..n] { - *v = (*v / a).clamp(0.0, 1.0); + let straight = *v / a; + // Float text can carry HDR and signed colours. Keep integer output bounded + // and retain the old handling of non-finite intermediate values. + *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c21d5c2682..148a84c5e8 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,44 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +#[test] +fn raster_preserves_hdr_and_signed_float_text_colors() { + let mut e = TextEngine::new(); + for alpha in [1.0, 0.4] { + let color = Color::rgba(2.5, -0.25, 0.5, alpha); + let style = CharStyle { size_pt: 48.0, color, ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, PixelFormat::RGBA32F); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + for channel in 0..3 { + assert!((pixel[channel] - color.c[channel]).abs() < 1e-5, "alpha={alpha}, pixel={pixel:?}"); + } + assert!(pixel[3] <= alpha + 1e-6); + } + assert!(covered > 100); + } +} + +#[test] +fn raster_integer_text_colors_stay_bounded() { + let mut e = TextEngine::new(); + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let style = CharStyle { size_pt: 48.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, format); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + assert_eq!(&pixel[..2], &[1.0, 0.0]); + assert!((pixel[2] - 0.5).abs() < 1.0 / 255.0); + assert!(pixel[3] <= 0.4 + 1.0 / 255.0); + } + assert!(covered > 100); + } +} + fn with_para(mut t: TextLayer, p: ParagraphStyle) -> TextLayer { t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: p }]; t From 20e8f949f9cb9bf0445721c26319f56872b44fdc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:23:55 +0200 Subject: [PATCH 114/223] Type: fall back to installed Indic fonts for document text (#4383) (#4386) * Type: fall back to installed Indic fonts for document text (#4383) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/text/src/fonts.rs | 42 ++++++++++++++++++++++++++++++- crates/text/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/type.toml | 2 +- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 433baee074..54cfeb471b 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -59,7 +59,7 @@ fn inflate_font(deflated: &[u8]) -> Vec { /// Families tried (if installed) after the requested one, for missing glyphs. The Southeast Asian /// and CJK families ([`THAI_FAMILIES`], [`KHMER_FAMILIES`], [`LAO_FAMILIES`], [`MYANMAR_FAMILIES`], -/// [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. +/// [`INDIC_FAMILIES`], [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"]; /// Thai-capable families: Windows (Leelawadee UI since Windows 8; Tahoma and Leelawadee on /// older systems), macOS (Thonburi, Sukhumvit Set) and Linux (Noto). Text in a font without Thai @@ -76,6 +76,38 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S /// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text) /// and Linux (Noto). pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"]; +/// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from +/// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a +/// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218). +pub const DEVANAGARI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Noto Sans Devanagari UI"]; +/// Bengali-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const BENGALI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Bangla", "Bangla Sangam MN", "Noto Sans Bengali", "Noto Sans Bengali UI"]; +/// Gurmukhi-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GURMUKHI_FAMILIES: &[&str] = &["Nirmala UI", "Gurmukhi Sangam MN", "Noto Sans Gurmukhi", "Noto Sans Gurmukhi UI"]; +/// Gujarati-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GUJARATI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Gujarati", "Gujarati Sangam MN", "Noto Sans Gujarati", "Noto Sans Gujarati UI"]; +/// Tamil-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TAMIL_FAMILIES: &[&str] = &["Nirmala UI", "Tamil Sangam MN", "Noto Sans Tamil", "Noto Sans Tamil UI"]; +/// Telugu-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TELUGU_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Telugu", "Telugu Sangam MN", "Noto Sans Telugu", "Noto Sans Telugu UI"]; +/// Kannada-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const KANNADA_FAMILIES: &[&str] = &["Nirmala UI", "Kannada Sangam MN", "Noto Sans Kannada", "Noto Sans Kannada UI"]; +/// Malayalam-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const MALAYALAM_FAMILIES: &[&str] = &["Nirmala UI", "Malayalam Sangam MN", "Noto Sans Malayalam", "Noto Sans Malayalam UI"]; +/// Sinhala-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const SINHALA_FAMILIES: &[&str] = &["Nirmala UI", "Sinhala Sangam MN", "Noto Sans Sinhala", "Noto Sans Sinhala UI"]; +/// All Indic-script families, in the order they join the fallback candidates. +pub const INDIC_FAMILIES: &[&[&str]] = &[ + DEVANAGARI_FAMILIES, + BENGALI_FAMILIES, + GURMUKHI_FAMILIES, + GUJARATI_FAMILIES, + TAMIL_FAMILIES, + TELUGU_FAMILIES, + KANNADA_FAMILIES, + MALAYALAM_FAMILIES, + SINHALA_FAMILIES, +]; /// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get /// the locale's forms instead of Arial Unicode's. const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"]; @@ -87,6 +119,14 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s v.extend_from_slice(KHMER_FAMILIES); v.extend_from_slice(LAO_FAMILIES); v.extend_from_slice(MYANMAR_FAMILIES); + for fams in INDIC_FAMILIES { + for f in *fams { + // Nirmala UI covers all nine scripts and is listed by each; probe it once. + if !v.contains(f) { + v.push(f); + } + } + } for s in order { // craft-fonts' Japanese fonts (if built in) go ahead of the installed Japanese fonts, in // the Japanese slot of the locale order, so shared Han keeps the locale's forms. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 148a84c5e8..832f0f0d85 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1655,6 +1655,59 @@ fn southeast_asian_fallback_candidates_cover_every_platform() { } } +/// The Indic scripts of document text, with conjuncts, matras and viramas (#218). +const INDIC: &[(&str, &str, &[&str])] = &[ + ("Devanagari", "हिन्दी भाषा क्षत्रिय श्री", fonts::DEVANAGARI_FAMILIES), + ("Bengali", "বাংলা ভাষা ক্ষত্রিয় শ্রী", fonts::BENGALI_FAMILIES), + ("Gurmukhi", "ਪੰਜਾਬੀ ਭਾਸ਼ਾ ਸਤਿ ਸ੍ਰੀ", fonts::GURMUKHI_FAMILIES), + ("Gujarati", "ગુજરાતી ભાષા ક્ષત્રિય શ્રી", fonts::GUJARATI_FAMILIES), + ("Tamil", "தமிழ் மொழி ஸ்ரீ கொடை", fonts::TAMIL_FAMILIES), + ("Telugu", "తెలుగు భాష క్షత్రియ శ్రీ", fonts::TELUGU_FAMILIES), + ("Kannada", "ಕನ್ನಡ ಭಾಷೆ ಕ್ಷತ್ರಿಯ ಶ್ರೀ", fonts::KANNADA_FAMILIES), + ("Malayalam", "മലയാളം ഭാഷ ക്ഷത്രിയ ശ്രീ", fonts::MALAYALAM_FAMILIES), + ("Sinhala", "සිංහල භාෂාව ක්‍ෂත්‍රිය ශ්‍රී", fonts::SINHALA_FAMILIES), +]; + +#[test] +fn indic_scripts_fall_back_to_installed_fonts() { + for (name, sample, families) in INDIC { + let mut e = TextEngine::with_system_fonts(); + let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else { + eprintln!("skipped: no {name}-capable font installed"); + continue; + }; + let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() }; + let l = e.layout(&direct, 72.0); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}"); + let mixed = format!("Latin {sample}"); + let l = e.layout(&point(&mixed, 24.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed"); + let latin = l.glyphs.first().map(|g| g.face); + assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face"); + } +} + +#[test] +fn indic_fallback_candidates_cover_every_platform() { + // Logic-level half (runs without these fonts): every script has a Windows, macOS and Linux + // family in the candidates, ahead of the broad last-resort fonts; the shared Nirmala UI is + // probed once. + for order in [crate::cjk::script_order(None), crate::cjk::script_order(Some("ja"))] { + let fb = fonts::fallback_candidates(&order); + let last = fb.iter().position(|f| *f == "Arial Unicode MS").unwrap(); + assert_eq!(fb.iter().filter(|f| **f == "Nirmala UI").count(), 1, "{fb:?}"); + for (name, _, families) in INDIC { + assert!(families.contains(&"Nirmala UI"), "{name}"); + assert!(families.iter().any(|f| f.ends_with("Sangam MN") || f.starts_with("Kohinoor")), "{name}: no macOS family"); + assert!(families.iter().any(|f| f.starts_with("Noto Sans ")), "{name}: no Linux family"); + for fam in *families { + let i = fb.iter().position(|f| f == fam).unwrap_or_else(|| panic!("{fam} missing from {fb:?}")); + assert!(i < last, "{fam} after the last-resort fonts"); + } + } + } +} + #[test] fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { diff --git a/docs/scorecard.md b/docs/scorecard.md index 712a17b611..db11f4050b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -212,7 +212,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w |---|---|---|---|---| | TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | -| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) | +| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | diff --git a/scorecard/type.toml b/scorecard/type.toml index 81064ce8d3..078c956a3f 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -23,7 +23,7 @@ id = "TYPE-218-3" target = "Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL" status = "partial" issue = 218 -note = "Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test)" +note = "Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test)" [[item]] id = "TYPE-218-4" From 88f84aab59b2bf4b2acd959129600bedddbb1a77 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:23:59 +0200 Subject: [PATCH 115/223] Raw: treat DNG samples piled up below the stated white level as clipped (fixes #4348) (#4389) Some DNG writers state a white level the sensor never reaches (an iPhone 15 Pro Max Bayer DNG states 4095 and clips at 3567). Development clips each channel at 1 after white balance, so red and blue reached 1 while green stayed at 0.85 and blown highlights turned magenta. On decode, a per-colour pile-up clearly below the white level is now raised to it, as in storytold/lightcraft#586. --- crates/raw/src/lib.rs | 10 +- crates/raw/src/saturation.rs | 265 +++++++++++++++++++++++++++++++++++ crates/raw/tests/dng.rs | 25 ++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 crates/raw/src/saturation.rs diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index dc1805d478..d5a3e987c7 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -60,6 +60,7 @@ mod par; mod preview; mod raf; mod rw2; +mod saturation; mod sensor; mod sony; mod tiff; @@ -230,7 +231,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { _ => { let t = Tiff::new(bytes).ok_or(RawError::NotRaw)?; match format { - RawFormat::Dng => dng::decode(&t, limits), + RawFormat::Dng => dng::decode(&t, limits).map(lift_clipped), RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), @@ -240,6 +241,13 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { } } +/// DNG writers can state a white level the sensor never reaches; samples stuck at the real saturation +/// point are raised to it so they clip together (see `saturation.rs`). +fn lift_clipped(mut s: Sensor) -> Sensor { + s.lift_clipped_samples(); + s +} + /// Decodes a lossless JPEG (T.81 process 14) stream into `(width, height, /// components, samples)`; at most `max_samples` samples are allocated. /// Exposed for fuzzing and tools. diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs new file mode 100644 index 0000000000..a36692a3f6 --- /dev/null +++ b/crates/raw/src/saturation.rs @@ -0,0 +1,265 @@ +//! Sensors that clip below the stated white level. +//! +//! A DNG's `WhiteLevel` is what the writer says the samples top out at, not always where the sensor +//! really saturates: a camera app can state 4095 for 12-bit data that piles up at 3567 (85 % of the +//! range above black, storytold/photocraft#4348, as storytold/lightcraft#548). Development clips each +//! channel at 1 after white balance, so samples stuck 15 % below the white level are not clipped +//! together: red and blue (multiplied by more than green) reach 1, green stays at 0.85, and blown +//! highlights turn magenta. +//! +//! [`Sensor::lift_clipped_samples`] finds the real saturation point of each colour from the samples +//! (a pile-up of identical values near the top of the range) and raises the samples at or above it to +//! the stated white level. Unclipped samples keep their values, and a file whose data reaches its white +//! level, or has no pile-up, is left alone. Same detector as storytold/lightcraft#586. + +use crate::sensor::Sensor; + +/// A saturation plateau must hold at least this share of its colour's samples (and 100 of them), as +/// many must lie below it (a scene, not a flat field)... +const MIN_SHARE: f64 = 1e-4; +/// ...be at least this many times as populated as the average value just below it... +const MIN_SPIKE: f64 = 16.0; +/// ...and have next to nothing above it (a real clip point is the top of the data; a popular value in +/// the middle of sparse or curve-coded data has plenty of samples above): at most this share of the +/// plateau lies more than `ABOVE_MARGIN` of the range over it (noise spilling past the plateau). +const MAX_ABOVE: f64 = 0.02; +const ABOVE_MARGIN: f32 = 0.01; +/// Plateaus within this share of the range from the white level are the white level (no lift needed). +const NEAR_WHITE: f32 = 0.03; +/// How far below the plateau the "average value just below it" reaches. +const NEIGHBOURHOOD: usize = 64; + +impl Sensor { + /// The value each colour's samples saturate at, when that is clearly below the stated white level + /// (see the module docs). Only CFA data without a linearization table is examined: with a table the + /// stored values are not on the white level's scale. + pub(crate) fn saturation_points(&self) -> [Option; 3] { + let none = [None; 3]; + let Some(cfa) = &self.cfa else { return none }; + if self.samples != 1 || self.linearization.is_some() || self.width.checked_mul(self.height) != Some(self.data.len()) { + return none; + } + let white = self.white[0]; + let black = if self.black.values.is_empty() { 0.0 } else { self.black.values.iter().sum::() / self.black.values.len() as f32 }; + // a hostile file can state any levels: NaN, negative or inverted ones are left alone + if !(black.is_finite() && white.is_finite() && black >= 0.0 && white > black && white <= 65535.0) { + return none; + } + let a = self.active; + let fits = |start: usize, len: usize, max: usize| start.checked_add(len).is_some_and(|end| end <= max); + if !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { + return none; + } + let lo = (black + 0.5 * (white - black)).ceil() as usize; + let hi = white as usize; + if lo + NEIGHBOURHOOD >= hi { + return none; + } + let mut hist = vec![[0u32; 3]; 65536]; + let mut total = [0u64; 3]; + for y in a.y..a.y + a.height { + let row = &self.data[y * self.width + a.x..][..a.width]; + for (x, &v) in (a.x..).zip(row) { + let c = usize::from(cfa.color(x, y)).min(2); + total[c] += 1; + hist[usize::from(v)][c] += 1; + } + } + let mut out = none; + for c in 0..3 { + // the most populated value of the upper half of the range + let Some((v, n)) = (lo..hi).map(|v| (v, hist[v][c])).max_by_key(|&(v, n)| (n, v)) else { continue }; + let min_n = (total[c] as f64 * MIN_SHARE).max(100.0); + if f64::from(n) < min_n || v as f32 >= white - NEAR_WHITE * (white - black) { + continue; + } + // a colour whose samples all sit on one value is a flat field, not a scene that clipped + let darker: u64 = hist[..v].iter().map(|h| u64::from(h[c])).sum(); + if (darker as f64) < min_n { + continue; + } + let below: u64 = (v.saturating_sub(NEIGHBOURHOOD)..v).map(|u| u64::from(hist[u][c])).sum(); + let from = (v + ((ABOVE_MARGIN * (white - black)) as usize).max(1) + 1).min(hist.len()); + let above: u64 = hist[from..].iter().map(|h| u64::from(h[c])).sum(); + if f64::from(n) >= MIN_SPIKE * (below as f64 / NEIGHBOURHOOD as f64).max(1.0) && above as f64 <= MAX_ABOVE * f64::from(n) { + out[c] = u16::try_from(v).ok(); + } + } + out + } + + /// Raises samples at or above their colour's real saturation point ([`Self::saturation_points`]) to + /// the stated white level, so development clips them together. Returns how many samples changed + /// (0: the data is untouched). + pub(crate) fn lift_clipped_samples(&mut self) -> usize { + let points = self.saturation_points(); + if points.iter().all(Option::is_none) { + return 0; + } + let Some(cfa) = self.cfa.clone() else { return 0 }; + let (a, w) = (self.active, self.width); + // the white level itself, rounded up: (white - black) / (white - black) is exactly 1 + let white = self.white[0].ceil() as u16; + let mut changed = 0; + for y in a.y..a.y + a.height { + let row = &mut self.data[y * w + a.x..][..a.width]; + for (x, v) in (a.x..).zip(row.iter_mut()) { + if let Some(p) = points[usize::from(cfa.color(x, y)).min(2)] + && *v >= p + && *v < white + { + *v = white; + changed += 1; + } + } + } + changed + } +} + +#[cfg(test)] +mod tests { + use crate::*; + + /// A 64 x 64 RGGB mosaic with a gradient, black 528, white `white`, whose samples above `clip` (when + /// given) are stuck at `clip` like a sensor that saturates early. + fn sensor_with(clip: Option, white: f32) -> Sensor { + let data = (0..64 * 64) + .map(|i| { + let v = 528 + ((i % 64) * 60 + (i / 64) * 7) as u16 % 4000; + clip.map_or(v, |c| v.min(c)) + }) + .collect(); + Sensor { + format: RawFormat::Dng, + make: None, + model: None, + width: 64, + height: 64, + samples: 1, + data, + cfa: Cfa::bayer("RGGB"), + linearization: None, + black: BlackLevels::uniform(528.0), + white: [white; 3], + active: Rect::new(0, 0, 64, 64), + crop: Rect::new(0, 0, 64, 64), + color: ColorInfo::default(), + camera_wb: None, + orientation: 1, + baseline_exposure: 0.0, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings: Vec::new(), + } + } + + /// The gradient of `sensor_with`, wrapped so it never gets near the top of the range. + fn without_top(s: &mut Sensor) { + for v in s.data.iter_mut() { + *v = 528 + (*v - 528) % 3000; + } + } + + #[test] + fn a_pile_up_below_the_white_level_is_found_and_lifted() { + let mut s = sensor_with(Some(3567), 4095.0); + assert_eq!(s.saturation_points(), [Some(3567); 3]); + let before = s.data.clone(); + assert!(s.lift_clipped_samples() > 100); + for (x, y) in before.iter().zip(&s.data) { + // plateau samples go to the white level, everything else is untouched + assert_eq!(*y, if *x >= 3567 { 4095 } else { *x }); + } + assert_eq!(s.lift_clipped_samples(), 0, "idempotent"); + } + + #[test] + fn data_that_reaches_its_white_level_is_left_alone() { + let mut s = sensor_with(Some(4095), 4095.0); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + #[test] + fn data_without_a_pile_up_is_left_alone() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + /// Sparse or curve-coded data has popular values with plenty of samples above them: not a clip point. + #[test] + fn a_popular_value_with_data_above_it_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for (i, v) in s.data.iter_mut().enumerate() { + // a third of the samples sit on one code in the upper half, a few hundred are brighter + if i % 3 == 0 { + *v = 3000; + } else if i % 11 == 0 { + *v = 3000 + (i % 700) as u16 + 1; + } + } + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + /// A uniform patch (a flat-field test frame) has nothing darker: not a scene that clipped. + #[test] + fn a_flat_field_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + s.data.fill(3000); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn a_few_stuck_samples_are_not_a_plateau() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for v in s.data.iter_mut().take(20) { + *v = 3800; + } + assert_eq!(s.saturation_points(), [None; 3]); + } + + /// With a linearization table the stored codes are not on the white level's scale. + #[test] + fn linearized_data_is_left_alone() { + let mut s = sensor_with(Some(3567), 4095.0); + s.linearization = Some((0..4096).collect()); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn hostile_levels_are_left_alone() { + for black in [-5000.0, f32::NAN] { + let mut s = sensor_with(Some(40), 4095.0); + s.black = BlackLevels::uniform(black); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + for white in [f32::NAN, f32::INFINITY, 1e9, 100.0] { + let mut s = sensor_with(Some(3567), 4095.0); + s.white = [white; 3]; + assert_eq!(s.saturation_points(), [None; 3], "{white}"); + } + // an active area that runs past the data (or overflows when added up) is refused + let mut s = sensor_with(Some(3567), 4095.0); + s.active = Rect::new(usize::MAX, 0, 2, 64); + assert_eq!(s.saturation_points(), [None; 3]); + s.active = Rect::new(0, 0, 64, 65); + assert_eq!(s.lift_clipped_samples(), 0); + let mut s = sensor_with(Some(3567), 4095.0); + s.data.pop(); + assert_eq!(s.lift_clipped_samples(), 0); + } +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9dfab5dfd7..e13fb920dd 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -425,6 +425,31 @@ fn highlights_clip_to_white_and_exposure_applies() { assert!((lin(up) / lin(base) - 2.0).abs() < 0.02, "{} vs {}", lin(up), lin(base)); } +/// storytold/photocraft#4348 (as storytold/lightcraft#548): a 12-bit DNG that states WhiteLevel 4095 +/// but whose sensor saturates at 3567 in every colour. Under the camera's white balance (red x2.46, +/// blue x1.59) the stuck red and blue samples clip at 1 while green stays at 0.85, so a blown wall +/// rendered magenta. The wall must come out neutral, and the unclipped ramp beside it unchanged. +#[test] +fn highlights_that_clip_below_the_stated_white_level_stay_neutral() { + let (w, h) = (64, 32); + let data: Vec = (0..w * h).map(|i| if i % w < w / 2 { 528 + ((i % w) * 90 + (i / w) * 3) as u16 } else { 3567 }).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.bits = 12; + spec.black = vec![528]; + spec.white = 4095; + spec.as_shot_neutral = Some([1.0 / 2.46, 1.0, 1.0 / 1.59]); + let b = spec.build(); + let d = develop(&b, &DevelopOptions::default()).unwrap(); + let px = &d.rgb[(16 * w + 48) * 3..(16 * w + 48) * 3 + 3]; + let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8); + let tint = (lin(px[0]) / lin(px[1]) - 1.0).abs().max((lin(px[2]) / lin(px[1]) - 1.0).abs()); + assert!(tint < 0.01, "the clipped wall is tinted: {px:?}"); + let s = sensor(&b); + for (i, (&got, &was)) in s.data.iter().zip(&data).enumerate() { + assert_eq!(got, if was == 3567 { 4095 } else { was }, "sample {i}"); + } +} + #[test] fn gain_map_opcode_is_applied() { // Uniform 0.25 grey; a gain map that doubles everything over the whole image. From 5aa6e33adf03b42620341178aa16b18679d5f633 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:24:03 +0200 Subject: [PATCH 116/223] Content-Aware Move: live preview of the result (#4385) (#4393) * Content-Aware Move: live preview of the result while the pointer rests (#4385) A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box. Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/retouch_cmds.rs | 1 + .../src/retouch_cmds/content_aware_move.rs | 58 +++- crates/engine/src/retouch_cmds/tests.rs | 25 ++ crates/ui-egui/src/cam_preview.rs | 285 ++++++++++++++++++ crates/ui-egui/src/cam_transform.rs | 18 +- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/lib.rs | 4 + docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 9 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/cam_preview.rs diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index cba7a22976..315a8d3929 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -27,6 +27,7 @@ mod content_aware_move; mod patch; mod pattern_stamp; mod remove; +pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; pub use patch::preview as patch_preview; fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index 120c8b7c02..ddca9c6e0f 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -96,6 +96,8 @@ struct Plan { mode: Mode, structure: u8, color: u8, + /// Most pixels the fills may sample around the content ([`MAX_MARGIN`], less for a preview). + margin: i32, } /// Transform On Drop's `scaleX` / `scaleY` (%) and `rotate` (degrees): the map from a source point @@ -193,7 +195,7 @@ fn landed_mask(cov: &[f32], area: Rect, inv: &Homography, rect: Rect) -> Vec Result { +fn plan(s: &Session, p: &Value, margin: i32) -> Result { let mode = match string(p, "mode", "move") { "move" => Mode::Move, "extend" => Mode::Extend, @@ -242,7 +244,7 @@ fn plan(s: &Session, p: &Value) -> Result { } }; let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color }) + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -293,7 +295,12 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec { /// Where a fill samples from around `r`: Photoshop's Content-Aware window /// ([`crate::fill_cmds::sampling_window`]), at most [`MAX_MARGIN`] pixels around `r`. pub(super) fn window(r: Rect, canvas: Rect) -> Rect { - crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(MAX_MARGIN)) + window_within(r, canvas, MAX_MARGIN) +} + +/// [`window`] with at most `margin` pixels around `r`. +fn window_within(r: Rect, canvas: Rect, margin: i32) -> Rect { + crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(margin)) } /// The pixels a step works on: the targeted surface, or with Sample All Layers the visible @@ -433,7 +440,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 1. The content at its new place: the content with its original surroundings laid over the // window there; never sampling the content's old place. - let win = window(plan.dest, plan.canvas); + let win = window_within(plan.dest, plan.canvas, plan.margin); let (content, moved) = match &inv { None => { let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; @@ -443,7 +450,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra Some(inv) => { // Only the content and a ring around it (the colour fit's reference) matter: read // the source behind that, a few pixels past the area, and resample it. - let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(MAX_MARGIN)).intersect(&plan.canvas); + let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(plan.margin)).intersect(&plan.canvas); let src = if src.is_empty() { plan.area } else { src }; (resample(&read(doc, plan.target, p, src)?, inv, win), landed_mask(&cov, plan.area, inv, win)) } @@ -454,7 +461,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 2. Move: fill the place the content left (where the content didn't land on it). if plan.mode == Mode::Move { ctx.check()?; - let win = window(plan.area, plan.canvas); + let win = window_within(plan.area, plan.canvas, plan.margin); let (w, h) = (win.width() as usize, win.height() as usize); let img = read(doc, plan.target, p, win)?; let landed = match &inv { @@ -478,7 +485,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra } pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { - let plan = plan(s, p)?; + let plan = plan(s, p, MAX_MARGIN)?; let label = if plan.mode == Mode::Move { "Content-Aware Move" } else { "Content-Aware Extend" }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); @@ -520,3 +527,40 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { }, ) } + +/// Largest selection (bounding box, in pixels) the live preview computes. +const PREVIEW_MAX_PIXELS: u64 = 256 * 256; +/// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve. +const PREVIEW_MARGIN: i32 = 48; + +/// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of +/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and +/// only small selections), so the result is close to, not the same as, what the command makes. +pub struct ContentAwarePreview { + doc: std::sync::Arc, + plan: Plan, + params: Value, +} + +impl ContentAwarePreview { + /// The active document with the move applied (nothing is recorded, the session is not + /// touched) and the rectangle it changed. Stops with `Cancelled` once `ctx` is cancelled. + pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> { + let mut work = (*self.doc).clone(); + let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?; + let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; + Ok((work, damage)) + } +} + +/// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors +/// like the command does, and for a selection larger than a preview takes. +pub fn preview(s: &Session, p: &Value) -> Result { + let plan = plan(s, p, PREVIEW_MARGIN)?; + let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height()); + if pixels > PREVIEW_MAX_PIXELS { + return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})"))); + } + let d = s.active().ok_or(EngineError::NoDocument)?; + Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() }) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index b566196738..625e702989 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -917,6 +917,31 @@ fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() { } } +#[test] +fn content_aware_move_preview_shows_the_result_without_touching_the_session() { + use crate::jobs::JobCtx; + let mut s = session(128, 64, 8, "rgb"); + paint_layer(&mut s, with_object(20, 26)); + s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap(); + let before = s.active().unwrap().revision; + let p = json!({"offset": [70, 0]}); + let (doc, damage) = content_aware_move_preview(&s, &p).unwrap().run(&JobCtx::new()).unwrap(); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + assert!(is_object(px(&doc, 95, 31)) && !is_object(px(&doc, 25, 31)), "the preview shows the move"); + assert!(damage.contains(95, 31) && damage.contains(25, 31), "{damage:?}"); + // The session is untouched. + assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31))); + assert_eq!(s.active().unwrap().revision, before); + // A cancelled preview stops; a selection past the preview's size and bad params are errors. + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled))); + assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err()); + assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err()); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap(); + assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err()); +} + #[test] fn content_aware_move_transform_on_drop_scales_and_rotates_the_content() { // A horizontal 16×6 bar in a 36×36 selection, dropped 80 px right and turned 90° with 125% diff --git a/crates/ui-egui/src/cam_preview.rs b/crates/ui-egui/src/cam_preview.rs new file mode 100644 index 0000000000..4065e3353c --- /dev/null +++ b/crates/ui-egui/src/cam_preview.rs @@ -0,0 +1,285 @@ +//! Content-Aware Move drags and its Transform On Drop box shown with the result: once the pointer +//! (or the box) rests for a moment, the move is computed as a background job +//! ([`photocraft_engine::retouch_cmds::content_aware_move_preview`]: a smaller sampling window than +//! the command and only small selections) and the canvas shows the document with it. Any change of +//! the offset, scale or rotation cancels the job and starts the wait again, so the UI never waits +//! for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the document is +//! untouched until then. Not available on wasm (no threads). + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::jobs::JobCtx; +use serde_json::Value; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// Preview keys: `BASE + n`, one per result shown (see `canvas::display_doc`). +const BASE: u64 = 1 << 45; +/// How long the move must stay unchanged before a preview is computed. +const DEBOUNCE_MS: f64 = 150.0; +/// How often a running job is polled for its result. +const POLL_MS: u64 = 40; + +struct Running { + key: String, + ctx: JobCtx, + rx: Receiver>, +} + +pub(crate) struct CamPreview { + doc: DocId, + revision: u64, + /// The params the move has now, and when they last changed (ms). + key: String, + since: f64, + /// Params whose preview failed (too large a selection, say): not tried again. + failed: Option, + running: Option, + /// The latest finished preview, with the params it shows. + shown: Option<(String, Arc)>, + serial: u64, +} + +impl CamPreview { + fn stop(&mut self) { + if let Some(r) = self.running.take() { + r.ctx.cancel(); + } + } +} + +impl Drop for CamPreview { + fn drop(&mut self) { + self.stop(); + } +} + +/// `paint.contentAwareMove`'s params for what the pointer or the box has now; `None` without a +/// move to preview. +fn wanted(app: &mut PhotocraftApp) -> Option { + if app.ui.tool != Tool::ContentAwareMove || cfg!(target_arch = "wasm32") { + return None; + } + if let Some(t) = &app.ui.cam_transform { + let place = crate::cam_transform::placement(t)?; + if place.offset == [0, 0] && place.is_plain_move() { + return None; + } + return Some(crate::cam_transform::params(app, &place)); + } + let d = app.drag.as_ref().filter(|d| d.tool == Tool::ContentAwareMove)?; + let (start, mods) = (d.start, d.modifiers); + let end = d.points.last().map_or(start, |p| [p[0], p[1]]); + if !crate::retouch_ui::patch_drags_selection(app, start, mods) { + return None; + } + let off = crate::retouch_ui::patch_offset(app, start, end); + (off != [0, 0]).then(|| crate::retouch_ui::content_aware_move_params(app, off.map(i64::from))) +} + +/// The document to show for the move on document `idx`: with the move applied, once its preview +/// has been computed for the current offset and transform. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + if app.session.active_index() != Some(idx) { + return None; + } + step(app, crate::gpu_canvas::now_ms()) +} + +fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc, u64)> { + let Some(params) = wanted(app) else { + app.cam_preview = None; + return None; + }; + let st = app.session.active()?; + let (doc, revision) = (st.doc.id, st.revision); + let key = params.to_string(); + if app.cam_preview.as_ref().is_some_and(|p| p.doc != doc || p.revision != revision) { + app.cam_preview = None; + } + let p = app.cam_preview.get_or_insert_with(|| CamPreview { + doc, + revision, + key: String::new(), + since: now, + failed: None, + running: None, + shown: None, + serial: 0, + }); + if p.key != key { + p.key = key.clone(); + p.since = now; + // What is computing is already out of date. + if p.running.as_ref().is_some_and(|r| r.key != key) { + p.stop(); + } + } + if let Some(r) = &p.running { + match r.rx.try_recv() { + Ok(result) => { + let r = p.running.take(); + match (result, r) { + (Ok(d), Some(r)) => { + // Duotone documents display through their inks. + let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d); + p.shown = Some((r.key, Arc::new(d))); + p.serial += 1; + } + (_, Some(r)) => p.failed = Some(r.key), + _ => {} + } + } + Err(TryRecvError::Disconnected) => { + if let Some(r) = p.running.take() { + p.failed = Some(r.key); + } + } + Err(TryRecvError::Empty) => {} + } + } + let settled = now - p.since >= DEBOUNCE_MS; + let done = p.shown.as_ref().is_some_and(|s| s.0 == key); + if settled && !done && p.running.is_none() && p.failed.as_deref() != Some(&key) { + match start(app, ¶ms, key.clone()) { + Some(r) => app.cam_preview.as_mut()?.running = Some(r), + None => app.cam_preview.as_mut()?.failed = Some(key.clone()), + } + } + let p = app.cam_preview.as_ref()?; + // Only a preview of the move as it is now: an older one would show a wrong place. + let (_, d) = p.shown.as_ref().filter(|s| s.0 == key)?; + Some((d.clone(), BASE + p.serial)) +} + +/// Compute the preview of `params` on a worker thread. +fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option { + let job = photocraft_engine::retouch_cmds::content_aware_move_preview(&app.session, params).ok()?; + let ctx = JobCtx::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = ctx.clone(); + std::thread::Builder::new() + .name("cam-preview".into()) + .spawn(move || { + let _ = tx.send(job.run(&worker).map(|(d, _)| d)); + }) + .ok()?; + Some(Running { key, ctx, rx }) +} + +/// Keep frames coming while a preview waits out its debounce or computes. +pub(crate) fn wake(app: &PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &app.cam_preview else { return }; + if p.running.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(POLL_MS)); + } else if p.shown.as_ref().is_none_or(|s| s.0 != p.key) && p.failed.as_deref() != Some(&p.key) { + let left = (DEBOUNCE_MS - (crate::gpu_canvas::now_ms() - p.since)).max(1.0); + ctx.request_repaint_after(std::time::Duration::from_millis(left as u64)); + } +} + +/// Whether the canvas shows a preview. +pub(crate) fn showing(app: &PhotocraftApp) -> bool { + app.cam_preview.as_ref().is_some_and(|p| p.shown.as_ref().is_some_and(|s| s.0 == p.key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + use serde_json::json; + + fn blue(d: &Document, x: i32, y: i32) -> bool { + d.layers[0].surface().unwrap().rgba(x, y)[0] < 0.5 + } + + #[test] + fn the_drag_previews_the_move_after_it_rests_and_never_touches_the_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + let steps = app.session.active().unwrap().history.past_len(); + + // Nothing to preview before the patch moves. + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none() && app.cam_preview.is_none()); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + // Still moving: the wait starts and nothing is computed or shown yet. + assert!(step(&mut app, 1000.0).is_none()); + assert!(step(&mut app, 1000.0 + DEBOUNCE_MS / 2.0).is_none() && app.cam_preview.as_ref().unwrap().running.is_none()); + // At rest: the job runs, off this thread, and its result shows once it arrives. + let mut now = 1000.0 + DEBOUNCE_MS; + let mut shown = step(&mut app, now); + for _ in 0..600 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, key) = shown.expect("a preview once the pointer rested"); + assert!(key > BASE && showing(&app)); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30), "the preview shows the move"); + let doc = app.session.active().unwrap().doc.clone(); + assert!(blue(&doc, 22, 30) && !blue(&doc, 72, 30), "the document is untouched"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // Moving on drops the stale preview at once and cancels what is not shown yet. + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, now + 5.0).is_none() && !showing(&app)); + // Release commits the exact move; the preview is gone. + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 30.0 }, m); + assert!(step(&mut app, now + 10.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn the_transform_on_drop_box_previews_the_dropped_patch() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + app.ui.tool_options.cam_transform_on_drop = true; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m); + assert!(crate::cam_transform::active(&app)); + let (mut now, mut shown) = (0.0, step(&mut app, 0.0)); + for _ in 0..800 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, _) = shown.expect("the box shows the result"); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30)); + // Cancelling the box drops the preview. + crate::cam_transform::cancel(&mut app); + assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn a_selection_too_large_to_preview_is_not_retried() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 300, "height": 300})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 140.0, y: 100.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none()); + assert!(step(&mut app, DEBOUNCE_MS + 1.0).is_none()); + let p = app.cam_preview.as_ref().unwrap(); + assert!(p.running.is_none() && p.failed.as_deref() == Some(p.key.as_str())); + } +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 33912a28e8..27b0943a0f 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -112,6 +112,17 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { } } +/// `paint.contentAwareMove`'s params for the box's placement and the options bar. +pub(crate) fn params(app: &PhotocraftApp, place: &Placement) -> serde_json::Value { + let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); + if !place.is_plain_move() { + p["scaleX"] = place.scale[0].into(); + p["scaleY"] = place.scale[1].into(); + p["rotate"] = place.rotate.into(); + } + p +} + /// ↩ / ✓: the move with the transformed patch, as one background job and History step. pub(crate) fn commit(app: &mut PhotocraftApp) { cancel_stale(app); @@ -122,12 +133,7 @@ pub(crate) fn commit(app: &mut PhotocraftApp) { crate::retouch_ui::patch_hint(app); return; } - let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); - if !place.is_plain_move() { - p["scaleX"] = place.scale[0].into(); - p["scaleY"] = place.scale[1].into(); - p["rotate"] = place.rotate.into(); - } + let p = params(app, &place); if let Err(e) = app.run("paint.contentAwareMove", p) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4f78b853f2..051714aeba 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1076,6 +1076,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::patch_preview::display_doc(app, idx) { return shown; } + // Content-Aware Move drag or box: the result, once computed in the background. + if let Some(shown) = crate::cam_preview::display_doc(app, idx) { + return shown; + } // A blend mode hovered in the Layers panel. if let Some(shown) = crate::blend_preview::display_doc(app, idx) { return shown; @@ -1146,7 +1150,7 @@ const NAVIGATOR_SETTLE_MS: f64 = 200.0; /// of document `idx` and catches up on release: each step of the drag is a new preview, and a /// thumbnail composite of the whole document per step makes painting lag on large images. fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool { - crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some() + crate::move_ui::showing(app) || crate::patch_preview::showing(app) || crate::cam_preview::showing(app) || live_stroke(app, idx).is_some() } /// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture @@ -3485,6 +3489,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_tool::draw_overlay(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); crate::cam_transform::draw(app, &painter, &xf); + crate::cam_preview::wake(app, painter.ctx()); crate::distort_ui::draw_overlay(app, &painter, &xf); crate::perspective_crop_ui::draw_overlay(app, &painter, &xf); crate::retouch_ui::draw_source_marker(app, &painter, &xf); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 873437b260..e599845669 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,7 @@ pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +pub(crate) mod cam_preview; mod cam_transform; mod camera_raw_detail_ui; mod camera_raw_preview_ui; @@ -520,6 +521,8 @@ pub struct PhotocraftApp { pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, + /// Content-Aware Move drag or box shown with its result (`cam_preview`). + pub(crate) cam_preview: Option, /// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`). pub(crate) magnetic: magnetic_lasso_ui::Runtime, /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). @@ -747,6 +750,7 @@ impl PhotocraftApp { object_finder: None, blend_preview: None, patch_preview: None, + cam_preview: None, magnetic: Default::default(), secondary_erase: false, held_erase: false, diff --git a/docs/scorecard.md b/docs/scorecard.md index db11f4050b..465b7bc7a9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only) | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index c090d4f897..cbff50e333 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -31,7 +31,7 @@ id = "TOOL-213-4" target = "Content-Aware Move tool reusing inpaint completion" status = "partial" issue = 213 -note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only)" +note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections" [[item]] id = "TOOL-213-5" From 78c61e6df5aee4ef726044154e2234cc335fbd3e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:07 +0200 Subject: [PATCH 117/223] Swatches: an RGB group with the exact primaries and secondaries (#4302) (#4409) The built-in "Pure" group's hues are vivid but not pure (its green is #28C850), so picking it for text looked dull next to Photoshop's #00FF00. Add a Photoshop-style "RGB" group (#FF0000, #FFFF00, #00FF00, #00FFFF, #0000FF, #FF00FF) right after Grays. Pure is kept unchanged so existing documents, actions and saved swatches that use it keep their colours. --- crates/engine/src/presets/swatches.rs | 35 ++++++++++++++++++++++---- crates/engine/src/swatch_cmds/tests.rs | 21 ++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index eec8c2d0d8..eb666e1ec5 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -288,7 +288,9 @@ pub fn hex(c: [f32; 3]) -> String { // ------------------------------------------------------------------ defaults -/// The default library: 40 colours in four groups (our own palette). +/// The default library: 46 colours in five groups. "RGB" holds the exact primaries and +/// secondaries (#FF0000, #00FF00, ...) as Photoshop's RGB group does; the other four are our own +/// palette, whose "Pure" hues are vivid but not the pure channel values (#4302). pub fn builtin() -> Vec> { let rgb = |n: &str, c: [u8; 3]| Swatch::new(n, SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0))); let grays = [ @@ -333,8 +335,19 @@ pub fn builtin() -> Vec> { let row = |prefix: &str, cs: [[u8; 3]; 10]| -> Vec { hues.iter().zip(cs).map(|(h, c)| rgb(&if prefix.is_empty() { (*h).to_string() } else { format!("{prefix} {h}") }, c)).collect() }; + // Right after Grays, near the top of the panel (Grays stays first: new swatches land there). + // Photoshop's order: red, yellow, green, cyan, blue, magenta. + let exact: [(&str, [u8; 3]); 6] = [ + ("RGB Red", [255, 0, 0]), + ("RGB Yellow", [255, 255, 0]), + ("RGB Green", [0, 255, 0]), + ("RGB Cyan", [0, 255, 255]), + ("RGB Blue", [0, 0, 255]), + ("RGB Magenta", [255, 0, 255]), + ]; vec![ Group::new("Grays", grays.iter().map(|(n, v)| rgb(n, [*v; 3])).collect()), + Group::new("RGB", exact.iter().map(|(n, c)| rgb(n, *c)).collect()), Group::new("Pastel", row("Pastel", pastel)), Group::new("Pure", row("", pure)), Group::new("Dark", row("Dark", dark)), @@ -346,15 +359,27 @@ mod tests { use super::*; #[test] - fn default_library_has_the_forty_colours() { + fn default_library_has_the_forty_six_colours() { let b = builtin(); - assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 40); + assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 46); let names: Vec<&str> = b.iter().flat_map(|g| g.items.iter().map(|s| s.name.as_str())).collect(); let mut uniq = names.clone(); uniq.sort_unstable(); uniq.dedup(); - assert_eq!(uniq.len(), 40, "names are unique: {names:?}"); - assert_eq!(b[2].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + assert_eq!(uniq.len(), 46, "names are unique: {names:?}"); + assert_eq!(b[3].name, "Pure"); + assert_eq!(b[3].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + } + + #[test] + fn the_rgb_group_holds_exact_primaries_and_secondaries() { + // #4302: picking green should give #00FF00, as in Photoshop's RGB group. + let b = builtin(); + let g = &b[1]; + assert_eq!(g.name, "RGB"); + let conv = SwatchConv::builtin(); + let hexes: Vec = g.items.iter().map(|s| hex(conv.rgb(&s.color))).collect(); + assert_eq!(hexes, ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff"]); } #[test] diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index cb121a1832..836b843971 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -14,10 +14,11 @@ fn export_bytes(s: &mut Session, p: Value) -> Vec { fn defaults_and_listing() { let mut s = Session::new(); let r = s.execute("swatches.list", json!({})).unwrap(); - assert_eq!(r["count"], 40); + assert_eq!(r["count"], 46); assert_eq!(r["groups"][0]["name"], "Grays"); assert_eq!(r["groups"][0]["swatches"][0], json!({"name": "Black", "model": "rgb", "values": [0.0, 0.0, 0.0], "hex": "#000000"})); - assert_eq!(r["groups"][2]["swatches"][0]["hex"], "#e62828"); + assert_eq!(r["groups"][1]["swatches"][2]["hex"], "#00ff00", "RGB Green is exact (#4302)"); + assert_eq!(r["groups"][3]["swatches"][0]["hex"], "#e62828"); } #[test] @@ -67,7 +68,7 @@ fn rename_delete_move_and_groups() { assert_eq!(s.presets.swatches[0].items[0].name, "White"); let r = s.execute("swatches.move", json!({"swatch": "White", "to": "Dark", "position": 1})).unwrap(); assert_eq!(r, json!({"group": "Dark", "position": 1})); - assert_eq!(s.presets.swatches[3].items[1].name, "White"); + assert_eq!(s.presets.swatches[4].items[1].name, "White"); // Out-of-range positions clamp. let r = s.execute("swatches.move", json!({"swatch": "White", "position": 999_999})).unwrap(); assert_eq!(r["position"], 10); @@ -80,21 +81,21 @@ fn rename_delete_move_and_groups() { assert!(s.execute("swatches.renameGroup", json!({"group": "Favourites", "name": "Dark"})).is_err()); let r = s.execute("swatches.deleteGroup", json!({"group": "Favourites", "keepSwatches": true})).unwrap(); assert_eq!(r["kept"], 2); - assert!(s.presets.swatches[3].items.iter().any(|i| i.name == "Red"), "kept in the group above"); + assert!(s.presets.swatches[4].items.iter().any(|i| i.name == "Red"), "kept in the group above"); s.execute("swatches.deleteGroup", json!({"group": "Mine 1"})).unwrap(); // Delete several by name, and one by index. let r = s.execute("swatches.delete", json!({"swatch": ["Red", "Blue", "Red"]})).unwrap(); assert_eq!(r["deleted"].as_array().unwrap().len(), 2); let r = s.execute("swatches.delete", json!({"index": 0})).unwrap(); assert_eq!(r["deleted"], json!(["Ink Black"])); - assert_eq!(names(&s).len(), 37); + assert_eq!(names(&s).len(), 43); // Reset restores the defaults; append adds them again with unique names. s.execute("swatches.reset", json!({})).unwrap(); - assert_eq!(names(&s).len(), 40); + assert_eq!(names(&s).len(), 46); s.execute("swatches.reset", json!({"append": true})).unwrap(); - assert_eq!(names(&s).len(), 80); + assert_eq!(names(&s).len(), 92); assert!(names(&s).contains(&"Black 2".to_string())); - assert_eq!(s.presets.swatches[4].name, "Grays 1"); + assert_eq!(s.presets.swatches[5].name, "Grays 1"); } #[test] @@ -220,7 +221,7 @@ fn bad_params_fail_gracefully() { ("swatches.delete", json!({"swatch": "Nope"})), ("swatches.delete", json!({"swatch": ["Red", 5]})), ("swatches.delete", json!({"swatch": ["Red", "Nope"]})), - ("swatches.delete", json!({"index": 40})), + ("swatches.delete", json!({"index": 46})), ("swatches.delete", json!({"index": "0"})), ("swatches.delete", json!({"swatch": "Red", "group": "Grays"})), ("swatches.rename", json!({"swatch": "Red"})), @@ -264,7 +265,7 @@ fn bad_params_fail_gracefully() { assert_eq!(s.presets.swatches, before, "failed commands change nothing"); // Empty library: the swatch commands are disabled, not crashing. s.execute("swatches.delete", json!({"swatch": names(&s)})).unwrap(); - for g in ["Grays", "Pastel", "Pure", "Dark"] { + for g in ["Grays", "RGB", "Pastel", "Pure", "Dark"] { s.execute("swatches.deleteGroup", json!({"group": g})).unwrap(); } for id in ["swatches.delete", "swatches.rename", "swatches.move", "swatches.use", "swatches.export", "swatches.renameGroup", "swatches.deleteGroup"] { From 1f76d839e3825fc3f1bf121c76e8bf252b4a8365 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:11 +0200 Subject: [PATCH 118/223] SVG: a group clipped by one path opens as editable shapes with a vector mask (#4266) (#4425) --- crates/io/src/svg.rs | 90 +++++++++++++++++++++++++++++++++++++++--- crates/io/tests/svg.rs | 3 +- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index b6a7741d6e..04fc4bee3a 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock}; use photocraft_color::blend::BlendMode; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::effects::GradientStyle; -use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, MAX_GROUP_DEPTH}; use photocraft_geom::{Point, Rect, Size}; use photocraft_raster::Surface; @@ -348,8 +348,11 @@ impl Conv { fn convert_node(&mut self, node: &usvg::Node, out: &mut Vec, depth: usize, rec: usize, parent: tiny_skia::Transform) { match node { usvg::Node::Group(g) => { + // A clip-path of one plain path becomes the group's vector mask (#4266); others + // are rasterised with the group. + let clip = g.clip_path().and_then(|cp| self.clip_mask(g, cp)); let dropped: [(bool, &'static str); 3] = - [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some(), "clip-path"), (g.mask().is_some(), "mask")]; + [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some() && clip.is_none(), "clip-path"), (g.mask().is_some(), "mask")]; let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION; if dropped.iter().any(|d| d.0) || too_deep { for (yes, what) in dropped { @@ -366,7 +369,7 @@ impl Conv { } // A group that only carries a transform (baked into its paths by usvg) or that // usvg inserted for its own bookkeeping adds nothing to the layer tree. - let plain = g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; + let plain = clip.is_none() && g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform()); if children.is_empty() { return; @@ -376,9 +379,14 @@ impl Conv { return; } let only_id = g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; - if only_id && children.len() == 1 { + // A lone child takes the group's name (and its clip, when it has no vector mask of + // its own). + if only_id && children.len() == 1 && (clip.is_none() || children.iter().all(|c| c.vector_mask.is_none())) { if let Some(mut child) = children.into_iter().next() { - child.name = g.id().to_string(); + if !g.id().is_empty() { + child.name = g.id().to_string(); + } + child.vector_mask = clip.or(child.vector_mask); out.push(child); } return; @@ -386,6 +394,7 @@ impl Conv { let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None })); l.opacity = g.opacity().get(); l.blend = blend(g.blend_mode()); + l.vector_mask = clip; out.push(l); } usvg::Node::Path(p) => out.extend(self.shape_layer(node, p, parent)), @@ -412,6 +421,29 @@ impl Conv { } } + /// The vector mask for group `g` clipped by `cp`, when the clip is one visible path with no + /// clip of its own: what resvg draws for the clip (the group's transform, then the clip's, + /// then the path), in canvas pixels, with the clip rule as its fill rule. + fn clip_mask(&self, g: &usvg::Group, cp: &usvg::ClipPath) -> Option { + if cp.clip_path().is_some() { + return None; + } + let [usvg::Node::Path(p)] = cp.root().children() else { return None }; + if !p.is_visible() { + return None; + } + let ts = g.abs_transform().pre_concat(cp.transform()).post_concat(self.global); + let subpaths = knots(&(*p.data()).clone().transform(ts)?); + if subpaths.is_empty() { + return None; + } + let fill_rule = match p.fill().map(|f| f.rule()) { + Some(usvg::FillRule::EvenOdd) => FillRule::EvenOdd, + _ => FillRule::NonZero, + }; + Some(VectorMask::new(Path { subpaths, fill_rule, inverted: false })) + } + /// A path as a shape layer, or a pixel layer when its paint is a pattern. fn shape_layer(&mut self, node: &usvg::Node, p: &usvg::Path, parent: tiny_skia::Transform) -> Option { if !p.is_visible() { @@ -688,6 +720,54 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable + /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of + /// two shapes is still rasterised, with the warning. + #[test] + fn a_single_path_clip_becomes_the_groups_vector_mask() { + let svg = br##" + + + + + "##; + let r = import_svg("t", svg).unwrap(); + assert!(r.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", r.warnings); + let [group] = r.document.layers.as_slice() else { panic!("one group: {:?}", r.document.layers.len()) }; + let LayerContent::Group(g) = &group.content else { panic!("a group") }; + assert_eq!(g.children.len(), 2); + assert!(g.children.iter().all(|c| matches!(c.content, LayerContent::Shape(_))), "editable shapes"); + let mask = group.vector_mask.as_ref().expect("the clip as a vector mask"); + let xs: Vec = mask.path.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor.x)).collect(); + let (lo, hi) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); + assert!((lo - 12.0).abs() < 1e-3 && (hi - 52.0).abs() < 1e-3, "in the group's space: {lo}..{hi}"); + // Pixel for pixel what resvg draws, but for anti-aliasing along the edges. + let ours = photocraft_compose::flatten(&r.document).to_rgba8().pixels; + let tree = parse(svg).unwrap().tree; + let mut pm = tiny_skia::Pixmap::new(64, 64).unwrap(); + resvg::render(&tree, tiny_skia::Transform::default(), &mut pm.as_mut()); + let theirs: Vec = pm + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) + .collect(); + let off = ours.chunks(4).zip(theirs.chunks(4)).filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 48)).count(); + assert!(off < 64, "{off} pixels differ"); + // The report's file: PhotoCraft's own icon opens without the clip-path warning. + let icon = import_svg("photocraft.svg", include_bytes!("../../../assets/app-icon/photocraft.svg")).unwrap(); + assert!(icon.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", icon.warnings); + assert!(icon.document.layers.iter().any(|l| l.vector_mask.is_some()), "its rounded square is a vector mask"); + // Two shapes in the clip: rasterised, and said so. + let two = br##" + + "##; + let r = import_svg("t", two).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index adbdf08a03..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -148,10 +148,11 @@ fn gradients_are_approximated_with_a_warning() { #[test] fn clip_paths_masks_filters_patterns_and_images_are_rasterised() { + // A clip of one path becomes a vector mask (#4266); one of two shapes is still rasterised. let r = open(&format!( "\ \ - \ + \ \ \ \ From f586e7133ef302d241b42c3eaabe1881d38d55cc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:24:15 +0200 Subject: [PATCH 119/223] Compose: key the vector-mask cache by mask content, not a temporary's address (#4426) (#4428) RenderVectorMasks keyed compiled masks by the address of the VectorMask it was given. hiding_mask/effects_source pass temporary clones, whose addresses get reused, so a later layer received an earlier layer's compiled mask. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/advanced_tests.rs | 20 ++++++++++++++++++++ crates/compose/src/lib.rs | 17 +++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 9a8723c41d..c19b0dc92f 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -426,6 +426,26 @@ fn vector_mask_hides_effects() { } } +/// The per-render vector-mask cache must key masks by content: `hiding_mask` and +/// `effects_source` hand it temporary clones whose addresses get reused (#4426). +#[test] +fn vector_mask_cache_does_not_mix_up_layers() { + use photocraft_doc::{Path, Subpath, VectorMask}; + for depth in DEPTHS { + let mut d = doc(depth, true); + for (name, color, x0, x1) in [("a", BLUE, -4.0, 6.0), ("b", GREEN, 6.0, 16.0)] { + let mut l = solid(&d, name, full(&d), color); + l.effects.items = vec![inside_stroke()]; + l.advanced.vector_mask_hides_effects = true; + let path = Path::new(vec![Subpath::polygon(&[(x0, -4.0), (x1, -4.0), (x1, 12.0), (x0, 12.0)])]); + l.vector_mask = Some(VectorMask::new(path)); + d.layers.push(l); + } + check(&d, 2, 4, BLUE, "layer A inside its own mask"); + check(&d, 9, 4, GREEN, "layer B inside its own mask"); + } +} + #[test] fn defaults_take_the_plain_paths() { let d = doc(SampleType::U8, true); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ae9e53e39c..5931bea018 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -451,18 +451,27 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti Buffer { rect: out, px: acc } } +type VectorMaskSlot = std::sync::Arc>; + #[derive(Default)] struct RenderVectorMasks { - masks: std::sync::Mutex>>>, + /// Compiled masks by content. Callers pass temporary clones (see `advanced.rs`), so an + /// address cannot identify a mask: a dropped clone's address is reused by the next one. + masks: std::sync::Mutex>, } impl RenderVectorMasks { fn values(&self, mask: &photocraft_doc::VectorMask, rect: Rect) -> Vec { - // Addresses identify immutable mask instances only for this render's lifetime. - let key = std::ptr::from_ref(mask) as usize; let slot = { let mut masks = self.masks.lock().unwrap_or_else(|e| e.into_inner()); - masks.entry(key).or_default().clone() + match masks.iter().find(|(m, _)| m == mask) { + Some((_, slot)) => slot.clone(), + None => { + let slot = VectorMaskSlot::default(); + masks.push((mask.clone(), slot.clone())); + slot + } + } }; // Compilation is sequential. Rendering can enter Rayon and must happen after // initialization, so waiting tiles cannot re-enter a slot still being built. From b4aea45e1960346dc897bb82de21082eaf3d9b60 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:18 +0200 Subject: [PATCH 120/223] =?UTF-8?q?Actions:=20record=20File=20=E2=80=BA=20?= =?UTF-8?q?Save=20a=20Copy=20and=20play=20it=20back=20as=20a=20copy=20(#43?= =?UTF-8?q?73)=20(#4390)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/actions_cmds.rs | 7 ++-- crates/ui-egui/src/actions.rs | 4 +- .../src/discard_ui/tests/targa_tests.rs | 20 +++++++--- crates/ui-egui/src/file_open/tests.rs | 24 +++++++++++ crates/ui-egui/src/lib.rs | 9 ++++- crates/ui-egui/src/targa_options_ui.rs | 15 +++---- crates/ui-egui/src/view_cmds.rs | 40 +++++++++++++------ 7 files changed, 88 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3ee242d7d6..3b3ffd1f01 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -115,10 +115,11 @@ pub fn replayable(id: &str) -> bool { && (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) } -/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032). -/// The shell replays them; batch and droplets write to their destination folder instead. +/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path +/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder +/// instead. pub fn shell_save_command(id: &str) -> bool { - matches!(id, "file.save" | "file.saveAs") + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") } /// View-only menu commands recorded by the desktop shell. Headless playback reports diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 7902a39bba..e0f3713f2e 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -37,10 +37,12 @@ struct RenameAction { focused: bool, } -fn label_of(id: &str) -> String { +pub(crate) fn label_of(id: &str) -> String { photocraft_engine::commands::find(id) .map(|c| c.label) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) + // Shell menu items recorded as steps, like Save a Copy (#4373). + .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).map(|c| c.1)) .map(|label| label.trim_end_matches('…').to_string()) .unwrap_or_else(|| id.to_string()) } diff --git a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs index e4c83bda64..565fe01037 100644 --- a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs @@ -113,7 +113,7 @@ fn save_a_copy_to_a_targa_asks_and_keeps_the_document_unsaved() { crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); h.run_steps(3); assert_eq!(h.state().targa_options.as_ref().map(|p| (p.bits, p.copy)), Some((TgaBits::Bits32, true))); - assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, true).is_err(), "the waiting prompt is kept"); + assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept"); assert_eq!(h.state().targa_options.as_ref().map(|p| p.path.as_str()), Some("copy.tga")); h.key_press(Key::Enter); h.run_steps(3); @@ -140,8 +140,8 @@ fn targa_options_keeps_a_layered_save_as_a_copy() { assert_eq!(st.path, None); } -/// An action being recorded keeps a Targa Save As with its bits, answered in Targa Options or -/// scripted (#2032), and plays it back without asking; the dialog's Save a Copy isn't recorded. +/// An action being recorded keeps a Targa Save As or Save a Copy with its bits, answered in Targa +/// Options or scripted (#2032, #4373), and plays them back without asking. #[test] fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { let (mut h, writes) = targa_app(true, "copy.tga"); @@ -164,12 +164,22 @@ fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] ); let steps = &h.state().session.actions.list[0].steps; - assert_eq!(steps, &[("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32}))]); + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), + ("file.saveACopy".into(), json!({"path": "copy.tga", "tgaBits": 32})), + ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32})) + ] + ); writes.borrow_mut().clear(); let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); assert!(played.get("failed").is_none(), "{played}"); assert!(h.state().targa_options.is_none(), "playback doesn't ask"); - assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24))), ("s.tga".to_string(), asked(Some(32)))]); + assert_eq!( + writes.borrow().as_slice(), + &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] + ); } /// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes. diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 9a833b7ba1..67887877e1 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -777,6 +777,30 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } +/// #4373: Save a Copy is recorded in an action, like Save and Save As, and played back as a copy: +/// the document keeps its own file and unsaved changes. +#[test] +fn save_a_copy_is_recorded_and_replayed_as_a_copy() { + let (mut app, written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.open_file("/pics/work.pcraft", b"x").unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[0].steps.last(), Some(&("file.saveACopy".to_string(), json!({"path": "/pics/copy.png"})))); + assert_eq!(crate::actions::label_of("file.saveACopy"), "Save a Copy"); + written.borrow_mut().clear(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(written.borrow().iter().map(|(p, _)| p.as_str()).collect::>(), ["/pics/copy.png"]); + let st = app.session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/pics/work.pcraft"), "the document keeps its file"); + assert!(st.is_dirty(), "and its unsaved changes"); +} + /// #4400: a new document saved under a name is listed in Open Recent (and on the Home screen), /// as opening it is; a Save a Copy isn't, since the document doesn't live there. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e599845669..96158c024e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1288,7 +1288,7 @@ impl PhotocraftApp { // which records the step once answered. if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; - targa_options_ui::park(self, path.clone(), copy, true)?; + targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } // A layered TIFF asks about its layers first (Preferences › File Handling); the save @@ -1325,7 +1325,7 @@ impl PhotocraftApp { Ok(r) } - /// Replays a recorded `file.save` / `file.saveAs` step: no dialog or prompt, and the + /// 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 { if self.save_options_open() { @@ -1344,6 +1344,11 @@ impl PhotocraftApp { } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; let tga_bits = targa_options_ui::bits_param(params, id)?; + if id == "file.saveACopy" { + // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). + let doc = self.session.active().ok_or("no document")?.doc.clone(); + return crate::view_cmds::write_copy(self, &doc, path, tga_bits); + } self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits) } diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index afc7f60ba2..c2161a0c9b 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -20,9 +20,10 @@ pub struct Prompt { pub bits: TgaBits, /// Save a Copy: the document keeps its path and unsaved changes. pub copy: bool, - /// File › Save As: the answered save is recorded as a `file.saveAs` step, so an action being - /// recorded keeps it (#2032). The dialog's Save a Copy isn't recorded. - pub record: bool, + /// The step the answered save is recorded as, so an action being recorded keeps it: + /// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy) and + /// `file.saveACopy` for Save a Copy (#4373). + pub record: &'static str, } /// `true` when `path` names a Targa (`.tga`, and the rarer `.icb`, `.vda`, `.vst`). @@ -43,7 +44,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { if app.save_options_open() { return Err("Answer TIFF or Targa Options before starting another save".into()); } @@ -80,9 +81,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()}); let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); - if written.is_ok() && p.record { - // An action being recorded keeps the Save As with this answer (#2032). - app.session.journal.push(("file.saveAs".into(), step)); + if written.is_ok() { + // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). + app.session.journal.push((p.record.into(), step)); } match written { Err(e) => { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 585e5dd074..09d759ed45 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1387,7 +1387,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Result { let doc = app.session.active().ok_or("no document")?.doc.clone(); let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a); @@ -1398,27 +1399,40 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { let parked = app.with_document(doc.id, |app| { let ask = crate::targa_options_ui::wants_prompt(app, &path); if ask { - crate::targa_options_ui::park(app, path.clone(), true, false)?; + crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; } Ok(ask) })?; if parked { return Ok(json!({"path": path, "warnings": []})); } - let settings = app.save_settings(); - let export = app.services.export.as_ref().ok_or("no exporter configured")?; - let (bytes, warnings) = export(&doc, &path, &settings)?; - let write = app.services.write.as_mut().ok_or("no writer configured")?; - write(&path, &bytes)?; - let template = "Saved a copy as {path}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("path", &path)]); - app.ui.status_error = false; - let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(&path))]); - crate::notices::io_warnings(app, &what, &warnings); - Ok(json!({"path": path, "warnings": warnings})) + let r = write_copy(app, &doc, &path, None)?; + app.session.journal.push(("file.saveACopy".into(), json!({"path": path}))); + Ok(r) }) } +/// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's +/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one. +pub(crate) fn write_copy( + app: &mut PhotocraftApp, + doc: &photocraft_doc::Document, + path: &str, + tga_bits: Option, +) -> Result { + let settings = crate::ExportSettings { tga_bits, ..app.save_settings() }; + let export = app.services.export.as_ref().ok_or("no exporter configured")?; + let (bytes, warnings) = export(doc, path, &settings)?; + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(path, &bytes)?; + let template = "Saved a copy as {path}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]); + app.ui.status_error = false; + let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(path))]); + crate::notices::io_warnings(app, &what, &warnings); + Ok(json!({"path": path, "warnings": warnings})) +} + /// Layer › Smart Objects › Export Contents: saves the contents file under its own name by default. fn export_contents(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; From 2842973dc01c437e7318e37faf1fa90aa2e4545d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:35:39 +0200 Subject: [PATCH 121/223] Compose: no overflow or unbounded allocation on hostile coordinates (#4427) (#4431) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/adjust.rs | 20 ++++++++++++++++++++ crates/compose/src/effects.rs | 13 +++++++++++++ crates/compose/src/lib.rs | 11 ++++++++++- crates/compose/src/masks.rs | 20 +++++++++++++++++++- crates/compose/src/pattern.rs | 12 +++++++++++- crates/compose/src/proxy.rs | 9 ++++++--- crates/compose/src/tests.rs | 10 ++++++++++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index edba488262..3c5ae5e465 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -493,6 +493,10 @@ pub fn bayer4(x: i32, y: i32) -> f32 { /// Samples a 3D LUT (`n`³ RGB triplets, red fastest) at `c`, trilinear or tetrahedral. pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f32; 3] { + // A degenerate or short table can't be sampled: leave the colour alone. + if n < 2 || n.checked_pow(3).and_then(|c| c.checked_mul(3)).is_none_or(|len| lut.len() < len) { + return c; + } let m = (n - 1) as f32; let pos = c.map(|v| v.clamp(0.0, 1.0) * m); let i0 = pos.map(|p| (p.floor() as usize).min(n - 2)); @@ -1528,3 +1532,19 @@ mod tone_tests { /// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128, /// measured on Photoshop 27.11; a 0.5 pivot moved it to 130). pub const LEGACY_PIVOT: f32 = 128.0 / 255.0; + +#[cfg(test)] +mod lut3d_guard_tests { + use super::lut3d_sample; + + #[test] + fn degenerate_or_short_tables_leave_the_colour_alone() { + let c = [0.2, 0.4, 0.6]; + for tetra in [false, true] { + assert_eq!(lut3d_sample(&[], 0, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 1, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 2, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 24], usize::MAX, c, tetra), c); + } + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 33af1a08b6..97d08f00da 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -95,6 +95,10 @@ impl Map { /// Shifted copy (bilinear), outside reads `outside`. fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map { let mut out = Map::new(self.w, self.h, 0.0); + // A hostile distance (±1e30, inf, NaN) would overflow the i64 offsets below; no shift beyond + // 2^30 px can land inside a map, so clamp (non-finite shifts read as none). + let reach = |d: f32| if d.is_finite() { d.clamp(-1.0e9, 1.0e9) } else { 0.0 }; + let (dx, dy) = (reach(dx), reach(dy)); let (fx, fy) = (dx.floor(), dy.floor()); let (ax, ay) = (dx - fx, dy - fy); let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] }; @@ -3122,4 +3126,13 @@ mod tests { let diff: f32 = plain.v.iter().zip(&noisy.v).map(|(a, b)| (a - b).abs()).sum(); assert!(diff > 5.0, "glow noise changes the coverage: {diff}"); } + + #[test] + fn shifting_by_hostile_distances_does_not_overflow() { + let m = square_shape(12); + for d in [1.0e30, -1.0e30, f32::INFINITY, f32::NAN] { + let s = m.shifted(d, d, 0.0); + assert_eq!(s.v.len(), 144); + } + } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5931bea018..3047cb4881 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -88,6 +88,11 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer { render_tiled(doc, rect, RENDER_TILE) } +/// Most pixels one [`render`] call will produce (2^30, a 16 GiB buffer; 32768² px). Larger rects +/// (a hostile canvas size is up to 300000 px a side) return an empty buffer instead of aborting +/// on allocation; callers wanting more render in pieces. +pub const MAX_RENDER_PIXELS: u64 = 1 << 30; + /// [`render`] with an explicit tile size (tests check tile independence). pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); @@ -107,8 +112,12 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if rect.is_empty() { return Buffer::transparent(rect); } + // Refuse rects too large to hold: an empty buffer, not an allocation failure. + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } cx.set_fx_clip(rect); - if rect.width() as i32 <= tile && rect.height() as i32 <= tile { + if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) { return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT)); diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index c14f73e083..d70947ec7c 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -344,7 +344,15 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv), + Some((x0, y0, x1, y1)) => grow( + Rect::new( + (x0.floor() as i32).saturating_sub(2), + (y0.floor() as i32).saturating_sub(2), + (x1.ceil() as i32).saturating_add(2), + (y1.ceil() as i32).saturating_add(2), + ), + sv, + ), None => Rect::EMPTY, }; if let Some(m) = pixel @@ -761,4 +769,14 @@ mod tests { l.vector_mask = None; assert!(combined_mask(&l, canvas).is_none()); } + + #[test] + fn hostile_path_coordinates_do_not_overflow() { + let mut l = Layer::raster("l", PixelFormat::RGBA8); + let knots = [(-1.0e30, -1.0e30), (1.0e30, 0.0), (0.0, 1.0e30)].iter().map(|&(x, y)| Knot::corner(x, y)).collect(); + let mut path = Path::default(); + path.subpaths.push(Subpath { knots, closed: true, ..Default::default() }); + l.vector_mask = Some(VectorMask::new(path)); + let _ = combined_mask(&l, Rect::new(0, 0, 8, 8)); + } } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index a2c5c52fb9..d107ecf4e7 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -159,7 +159,7 @@ impl Tile { if wx == 0.0 { continue; } - let p = self.at(x0 + dx, y0 + dy); + let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy)); let k = wx * wy; for c in 0..4 { acc[c] += p[c] * k; @@ -287,4 +287,14 @@ mod tests { let (u, v) = rot.map(1.0, 3.0); assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + + #[test] + fn sampling_at_hostile_phases_does_not_overflow() { + let patterns = [checker()]; + let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES); + let t = prepared.get(&patterns[0].id, "").unwrap(); + for u in [1.0e30, -1.0e30, f64::INFINITY, f64::NAN] { + let _ = t.sample(u, u); + } + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 7fe7fc3983..84c9348029 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -20,12 +20,15 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let bpp = s.format().bytes_per_pixel(); let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); - let w = (x1 - x0).max(0) as usize; + let w = x1.saturating_sub(x0).max(0) as usize; let mut row = vec![0u8; w * bpp]; for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e8bf005e59..c13a423ca8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2488,6 +2488,16 @@ 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]); } +#[test] +fn render_refuses_rects_beyond_the_pixel_cap() { + let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + // 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path. + for tile in [crate::RENDER_TILE, 1 << 20] { + let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile); + assert!(b.px.is_empty() && b.rect.is_empty()); + } +} + // The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape // the maps are built from (a layer's own, or a styled group's children composite) without // touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer From 05e98e7ade3749345169c7f317e9e82154a84daf Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:35:54 +0200 Subject: [PATCH 122/223] =?UTF-8?q?Smart=20Filters:=20select=20a=20filter,?= =?UTF-8?q?=20drag=20or=20=E2=8C=A5-drag=20it=20onto=20another=20smart=20o?= =?UTF-8?q?bject=20(#4271)=20(#4371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/smart_cmds.rs | 78 +++++++++ crates/engine/src/smart_cmds/tests.rs | 63 +++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels.rs | 17 +- crates/ui-egui/src/smart_filter_rows_tests.rs | 164 ++++++++++++++++++ crates/ui-egui/src/smart_ui.rs | 91 +++++++++- docs/ui-parity.md | 4 +- 7 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/smart_filter_rows_tests.rs diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 635c5bb088..e108c4d7be 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1069,6 +1069,73 @@ fn move_filter(s: &mut Session, p: &Value) -> Result { }) } +/// Dragging smart filters onto another smart object in the Layers panel (#4271): they move there, +/// or are copied with `copy` (⌥-drag in Photoshop). One filter (`filter`) lands on top of the +/// target's filters; all of them (the Smart Filters row) are added on top too, with the filter +/// mask when the target has none. Both smart objects are re-rendered in one undo step. +fn transfer_filters(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.smartFilter.transfer"; + let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id"))); + let (from, to) = (id_of("from")?, id_of("to")?); + if from == to { + return Err(bad(CMD, "the smart filters are already on that layer")); + } + // The other `layer.smartFilter.*` commands name a filter `index`; here a guessed `index` + // would otherwise be dropped and every filter moved. + if p.get("index").is_some() { + return Err(bad(CMD, "name the filter with `filter` (no `filter`: all of them)")); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let src = smart(doc, from)?; + smart(doc, to).map_err(|_| bad(CMD, "smart filters can only be dropped on a smart object"))?; + let n = src.smart_filters.len(); + if n == 0 { + return Err(other("the smart object has no smart filters")); + } + let index = match p.get("filter") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|i| i as usize).filter(|i| *i < n).ok_or_else(|| bad(CMD, format!("`filter` must be 0..{n} (0 = bottom filter)")))?), + }; + let (filters, mask) = match index { + Some(i) => (vec![src.smart_filters[i].clone()], None), + None => (src.smart_filters.clone(), src.filter_mask.clone()), + }; + let label = match (copy, index.is_some()) { + (true, true) => "Copy Smart Filter", + (true, false) => "Copy Smart Filters", + (false, true) => "Move Smart Filter", + (false, false) => "Move Smart Filters", + }; + crate::jobs::edit_job( + s, + label, + move |doc, _, ctx| { + let dst = smart_mut(doc, to)?; + dst.smart_filters.extend(filters); + if dst.filter_mask.is_none() { + dst.filter_mask = mask; + } + refresh_or_fail_in(doc, to, ctx)?; + if !copy { + let src = smart_mut(doc, from)?; + match index { + Some(i) => { + src.smart_filters.remove(i); + } + None => { + src.smart_filters.clear(); + src.filter_mask = None; + } + } + refresh_or_fail_in(doc, from, ctx)?; + } + Ok(()) + }, + move |()| json!({"from": from.0, "to": to.0}), + ) +} + // ---------- enablement ---------- fn active_smart(s: &Session) -> std::result::Result<&SmartObject, String> { @@ -1088,6 +1155,9 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> { _ => Err("the smart object is embedded".into()), } } +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} fn has_smart_filters(s: &Session) -> std::result::Result<(), String> { if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) } } @@ -1269,6 +1339,14 @@ pub fn specs() -> Vec { ), spec!("layer.smartFilter.delete", "Delete Smart Filter", &[], r##"{"layer":id?,"index":u32?}"##, has_smart_filters, delete_filter), spec!("layer.smartFilter.move", "Move Smart Filter", &[], r##"{"layer":id?,"index":u32?,"to":u32}"##, has_smart_filters, move_filter), + spec!( + "layer.smartFilter.transfer", + "Move or Copy Smart Filters", + &[], + r##"{"from":id,"to":id,"filter":u32? (default: all, with the filter mask),"copy":bool=false}"##, + has_doc, + transfer_filters + ), ] } diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index aa88fe3fed..a6cdbf48d5 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -247,6 +247,69 @@ fn smart_filter_commands_check_an_explicit_layer_target() { assert_eq!(active(&s), curves); } +/// #4271: dragging smart filters onto another smart object moves them, ⌥-dragging copies them; +/// one filter or all of them (with the filter mask), each in one undo step. +#[test] +fn smart_filters_move_or_copy_to_another_smart_object() { + let mut s = session(8); + paint(&mut s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 5, "undefinedAreas": "transparent"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + let pixel = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let so = |s: &Session, id: u64| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("not smart: {}", other.kind_name()), + }; + let commands = |sm: &SmartObject| sm.smart_filters.iter().map(|f| f.command.clone()).collect::>(); + let (offset, blur) = ("filter.other.offset", "filter.blur.gaussianBlur"); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, a).filter_mask.is_some()); + let before = flat(&s); + + // ⌥-drag the blur: B gets a copy (re-rendered), A keeps it. + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 1, "copy": true})).unwrap(); + assert_eq!(commands(&so(&s, b)), [blur]); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, b).filter_mask.is_none(), "one filter comes without the mask"); + assert!(max_diff(&flat(&s), &before) > 1e-3, "B is blurred"); + s.undo(); + assert!(so(&s, b).smart_filters.is_empty()); + assert_eq!(flat(&s), before); + + // Drag the offset: it moves to the top of B's filters. + s.redo(); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + assert_eq!(commands(&so(&s, a)), [blur]); + assert_eq!(commands(&so(&s, b)), [blur, offset]); + + // Drag the Smart Filters row: all of A's filters and its filter mask move to B. + let mask = so(&s, a).filter_mask; + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b})).unwrap(); + assert!(so(&s, a).smart_filters.is_empty() && so(&s, a).filter_mask.is_none()); + assert_eq!(commands(&so(&s, b)), [blur, offset, blur]); + assert_eq!(so(&s, b).filter_mask, mask); + s.undo(); + assert_eq!(so(&s, a).smart_filters.len(), 1); + assert_eq!(so(&s, b).smart_filters.len(), 2); + + // Refused: onto itself, onto a layer that isn't a smart object, a bad index, no filters. + let transfer = |s: &mut Session, p: Value| s.execute("layer.smartFilter.transfer", p); + assert!(transfer(&mut s, json!({"from": a, "to": a})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": pixel})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "filter": 5})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "index": 0})).is_err(), "`index` isn't taken for `filter`"); + assert!(transfer(&mut s, json!({"from": pixel, "to": b})).is_err()); + s.execute("layer.smartFilter.clearSmartFilters", json!({"layer": a})).unwrap(); + assert!(transfer(&mut s, json!({"from": a, "to": b})).is_err()); + assert_eq!(so(&s, b).smart_filters.len(), 2, "nothing changed"); +} + #[test] fn selection_becomes_the_filter_mask() { let mut s = session(8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96158c024e..cc8d8bdf27 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2390,6 +2390,9 @@ mod color_tab_theme_tests; #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod smart_filter_rows_tests; + #[cfg(test)] mod move_outline_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 78f1715c98..3624dbbf5a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2247,8 +2247,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } - let held = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some()); + let held = ctx.data(|d| { + d.get_temp::(egui::Id::new("layer-drag")).is_some() + || d.get_temp::(fx_drag_key()).is_some() + || d.get_temp::(crate::smart_ui::drag_key()).is_some() + }); let dragging = held && ctx.input(|i| i.pointer.primary_down()); let pointer = ctx.input(|i| i.pointer.interact_pos()); let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt)); @@ -2332,6 +2337,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // An effect row can be dropped on Delete too (#3102), and a mask's thumbnail (#3113, #3119). let fx_footer_drag = ctx.data(|d| d.get_temp::(fx_drag_key())); fx_drag_feedback(&ctx, &doc); + // And a smart filter's row (#4271). + let filter_footer_drag = ctx.data(|d| d.get_temp::(crate::smart_ui::drag_key())); + crate::smart_ui::drag_feedback(&ctx, &doc); let mask_footer_drag = ctx.data(|d| d.get_temp::(mask_drag_key())); if let Some((_, kind)) = mask_footer_drag && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) @@ -2344,6 +2352,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } widgets::panel_footer(ui, |ui| { let active_layer = active.and_then(|id| doc.layer(id)); @@ -2363,6 +2372,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag)); + if let Some(drag) = filter_footer_drag + && dropped_on(ui, &trash) + { + actions.push(crate::smart_ui::trash_action(drag)); + } // Photoshop asks "Apply mask to layer before removing?" or "Delete the vector mask?" // (`mask_trash_prompt`). if let Some((layer, kind)) = mask_footer_drag @@ -2642,6 +2656,7 @@ fn layer_row( let mask_drag = mask_drag.is_some(); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); + crate::smart_ui::drop_on_row(ctx, ui, l, rect, actions); if resp.drag_started() && !mask_drag { crate::layer_transfer::begin_from_panel(app, ctx, l.id); } diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs new file mode 100644 index 0000000000..519402672c --- /dev/null +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -0,0 +1,164 @@ +//! Layers panel Smart Filters rows (#4271): a click selects a filter (and its smart object), a +//! drag onto another smart object moves it there, ⌥-drag copies it, and a drag onto Delete +//! removes it, as in Photoshop. + +use egui::accesskit::{Role, Toggled}; +use egui::{Modifiers, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Smart objects "Smart A" (Gaussian Blur and Offset) and "Smart B" (no filters), and a pixel +/// layer "Pixels" on top: (harness, A, B, pixels). +fn panel() -> (egui_kittest::Harness<'static, PhotocraftApp>, u64, u64, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let smart = |s: &mut photocraft_engine::Session, name: &str| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + id + }; + let a = smart(&mut s, "Smart A"); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 2})).unwrap(); + let b = smart(&mut s, "Smart B"); + let c = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": c, "name": "Pixels"})).unwrap(); + // 60 fps steps, so a double-click's two clicks fall inside egui's 0.3 s window. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(4); + (h, a, b, c) +} + +fn filters(h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64) -> Vec { + match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().map(|f| f.command.clone()).collect(), + _ => Vec::new(), + } +} + +/// The centre of the Layers panel row (or footer button) labelled `label`: rows are selectable +/// labels, which AccessKit calls buttons, unlike the same name drawn elsewhere. +fn at(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> Pos2 { + h.query_all_by_label(label).find(|n| n.accesskit_node().role() == Role::Button).unwrap_or_else(|| panic!("`{label}` drawn")).rect().center() +} + +fn selected(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +/// Press at `from`, move to `to` in steps and release there, with `modifiers` held on release. +fn drag(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn a_click_selects_the_filter_and_its_smart_object() { + let (mut h, a, _, c) = panel(); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + h.get_by_role_and_label(Role::Button, "Gaussian Blur").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(a)), "the smart object becomes active"); + assert!(selected(&h, "Gaussian Blur")); + assert!(!selected(&h, "Offset")); + // Another layer: the filter is no longer shown selected. + let pixels = at(&h, "Pixels"); + h.hover_at(pixels); + h.run_steps(1); + h.drag_at(pixels); + h.run_steps(1); + h.drop_at(pixels); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + assert!(!selected(&h, "Gaussian Blur")); +} + +#[test] +fn dragging_a_filter_onto_another_smart_object_moves_it_and_alt_copies_it() { + let (mut h, a, b, _) = panel(); + let (blur, offset) = ("filter.blur.gaussianBlur", "filter.other.offset"); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(filters(&h, b), [blur], "⌥-drag copies"); + assert_eq!(filters(&h, a), [blur, offset]); + let (from, to) = (at(&h, "Offset"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, b), [blur, offset], "a drag moves"); + assert_eq!(filters(&h, a), [blur]); +} + +#[test] +fn a_pixel_layer_does_not_take_smart_filters() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Pixels")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a).len(), 2); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +#[test] +fn dragging_filters_onto_delete_removes_them() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Offset"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a), ["filter.blur.gaussianBlur"]); + let (from, to) = (at(&h, "Smart Filters"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert!(filters(&h, a).is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); +} + +/// The report's case: the Camera Raw Filter is ⌥-dragged to another smart object with its +/// settings, and double-clicking the copy opens Camera Raw on that smart object's filter. +#[test] +fn a_camera_raw_filter_copies_and_its_copy_opens_on_the_target() { + let (mut h, a, b, _) = panel(); + h.state_mut().run("layer.select", json!({"layer": a})).unwrap(); + h.state_mut().run("filter.cameraRaw", json!({"exposure": 1.0, "contrast": 20})).unwrap(); + h.run_steps(4); + let (from, to) = (at(&h, "Camera Raw Filter"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + let raw = |h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64| match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().find(|f| f.command == "filter.cameraRaw").map(|f| f.params.clone()), + _ => None, + }; + assert_eq!(raw(&h, b), raw(&h, a), "copied with its settings"); + assert_eq!(raw(&h, b).unwrap()["exposure"], 1.0); + // B is above A in the panel, so its copy is the upper of the two Camera Raw rows. + let copy = h + .query_all_by_label("Camera Raw Filter") + .filter(|n| n.accesskit_node().role() == Role::Button) + .map(|n| n.rect().center()) + .min_by(|p, q| p.y.total_cmp(&q.y)) + .unwrap(); + for _ in 0..2 { + h.hover_at(copy); + h.drag_at(copy); + h.run_steps(1); + h.drop_at(copy); + h.run_steps(1); + } + h.run_steps(3); + let dialog = h.state().camera_raw.as_ref().expect("Camera Raw opened"); + assert_eq!(dialog.layer, LayerId(b)); +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..ed31092016 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,8 +1,9 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows -//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). +//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click +//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; -use photocraft_doc::{Layer, LayerContent, SmartObject}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -41,10 +42,20 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } let t = Tokens::get(ui.ctx()); let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let Some(st) = app.session.active() else { return }; + let (active, doc) = (st.active_layer == Some(l.id), st.doc.id); + let picked = ui.ctx().data(|d| d.get_temp::(selected_key(doc))); let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if resp.hovered() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp::(drag_key(), (l.id.0, index))); + } + // Selected as in Photoshop: the row stays highlighted while its smart object is active. + let selected = active && picked == Some((l.id.0, index)); + if selected { + ui.painter().rect_filled(rect, 0.0, if t.pro { t.row_selected } else { t.hover }); + } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } if t.pro { @@ -75,7 +86,14 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, &name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); + if resp.clicked() { + ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc), (l.id.0, index))); + if !active { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + } + } if let Some(i) = index && resp.double_clicked() { @@ -84,6 +102,67 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } } +/// Smart filter rows being dragged: the smart object and the filter index (`None` = the Smart +/// Filters row, all of them). Also what a click selects. +pub(crate) type FilterDrag = (u64, Option); + +pub(crate) fn drag_key() -> egui::Id { + egui::Id::new("smart-filter-drag") +} + +/// The selected smart filter row of a document. +fn selected_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("smart-filter-selected", doc)) +} + +/// While smart filters are dragged: their name by the pointer, and the copy cursor while ⌥ is held. +pub(crate) fn drag_feedback(ctx: &egui::Context, doc: &Document) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let label = match (filter, doc.layer(LayerId(from)).map(|l| &l.content)) { + (Some(i), Some(LayerContent::Smart(sm))) => sm.smart_filters.get(i).map_or_else(String::new, filter_label), + _ => "Smart Filters".to_string(), + }; + crate::layer_transfer::ghost(ctx, p, &label); + if ctx.input(|i| i.modifiers.alt) { + ctx.set_cursor_icon(egui::CursorIcon::Copy); + } +} + +/// The command for dropping the smart filters of `from` on the smart object `target`: they move +/// there, or with ⌥ held on release are copied (Photoshop). +pub(crate) fn drop_action(from: u64, filter: Option, target: LayerId, copy: bool) -> (String, Value) { + let mut payload = json!({"from": from, "to": target.0, "copy": copy}); + if let (Some(i), Some(o)) = (filter, payload.as_object_mut()) { + o.insert("filter".into(), json!(i)); + } + ("layer.smartFilter.transfer".into(), payload) +} + +/// Another smart object's row accepts the smart filters being dragged: outlined while the pointer +/// is over it, dropped on release. Other layers don't take them, as in Photoshop. +pub(crate) fn drop_on_row(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if from == l.id.0 || !matches!(l.content, LayerContent::Smart(_)) || !rect.contains(p) { + return; + } + let t = Tokens::get(ctx); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(drop_action(from, filter, l.id, ctx.input(|i| i.modifiers.alt))); + } +} + +/// Smart filters dropped on the footer's Delete button: a filter's row deletes that filter, the +/// Smart Filters row clears them all. +pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) { + match filter { + Some(i) => ("layer.smartFilter.delete".into(), json!({"layer": layer, "index": i})), + None => ("layer.smartFilter.clearSmartFilters".into(), json!({"layer": layer})), + } +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8c0e0d2580..708e5276a3 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-12 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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, @@ -210,6 +210,7 @@ Paragraph; the rest open as in-app floating windows. | 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 | +| 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) | ## Revision history @@ -226,3 +227,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | | 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) | From 0a32b3593d8348d37e302ddfa40689bb524ce831 Mon Sep 17 00:00:00 2001 From: Scott Campbell Date: Mon, 12 Oct 2026 22:41:38 -0400 Subject: [PATCH 123/223] Add Krita (.kra) import (#4388) Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested and pass-through groups, names, visibility, opacity, locks, blend modes and transparency masks, plus the image's ICC profile and resolution. Vector, filter, fill, file and clone layers and the other mask kinds are left out with a warning; float, CMYK and Lab documents open as Krita's merged image (preview). Import only: Save suggests .ora. Checked against 35 Krita 5.2.9-authored files. Co-authored-by: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- apps/photocraft-cli/src/lib.rs | 7 +- apps/photocraft-web/src/web.rs | 9 +- apps/photocraft/src/services.rs | 10 +- crates/automation/tests/krita.rs | 68 +++ crates/engine/src/file_cmds.rs | 5 +- crates/engine/tests/kra.rs | 53 +++ crates/io/src/kra.rs | 402 ++++++++++++++---- crates/io/src/kra/tests.rs | 287 +++++++++++++ crates/io/src/kra/tiles.rs | 259 +++++++++++ crates/io/src/lib.rs | 18 +- crates/io/tests/krita_corpus.rs | 12 +- crates/ui-egui/src/lib.rs | 9 +- docs/kra.md | 77 ++++ docs/ora.md | 13 +- .../linux/ai.storyteller.photocraft.desktop | 2 +- .../ai.storyteller.photocraft.metainfo.xml.in | 1 + packaging/macos/Info.plist.in | 16 + packaging/windows/photocraft.wxs | 4 + 19 files changed, 1139 insertions(+), 115 deletions(-) create mode 100644 crates/automation/tests/krita.rs create mode 100644 crates/engine/tests/kra.rs create mode 100644 crates/io/src/kra/tests.rs create mode 100644 crates/io/src/kra/tiles.rs create mode 100644 docs/kra.md diff --git a/README.md b/README.md index b6e8c5a601..5fd2650415 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable. + PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.

🪄 The everyday essentials

diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index ce1cbeaf9e..87050381a5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -438,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool { fn is_input(p: &Path) -> bool { let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub") + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) }; p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) @@ -674,6 +674,11 @@ mod missing_font_warning_tests { let (code, _, stderr) = invoke(&["convert", &ora, &png]); assert_eq!(code, 0, "{stderr}"); assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&folder); } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index e44a0e259b..8d7e8e0826 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -441,6 +441,7 @@ fn mime_for(name: &str) -> &'static str { Some("gif") => "image/gif", Some("psd" | "psb") => "image/vnd.adobe.photoshop", Some("ora") => "image/openraster", + Some("kra" | "krz") => "application/x-krita", _ => "application/octet-stream", } } diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 891e251bf1..e67f13ae43 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -16,13 +16,13 @@ use std::sync::Arc; #[cfg(test)] mod recovery_tests; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", + "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { ("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra"])), + ("Krita", open_filter_extensions(&["kra", "krz"])), ("SVG", open_filter_extensions(SVG_EXTS)), ] } diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs new file mode 100644 index 0000000000..85bde4f5fc --- /dev/null +++ b/crates/automation/tests/krita.rs @@ -0,0 +1,68 @@ +//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP +//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster. + +use photocraft_automation::Headless; +use serde_json::json; + +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +fn tiles(bgra: [u8; 4]) -> Vec { + let n = 64 * 64; + let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); + out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); + out.push(0); + for b in bgra { + out.extend(std::iter::repeat_n(b, n)); + } + out +} + +fn kra() -> Vec { + let maindoc = r#" + + + + + + + +"#; + let mut z = photocraft_format::zip::ZipWriter::new(); + z.add("mimetype", b"application/x-krita").unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap(); + z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap(); + z.finish().unwrap() +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + h.handle("engine.execute", json!({"command": command, "params": params})).unwrap() +} + +#[test] +fn krita_documents_open_with_layers_and_save_as_openraster() { + let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("art.kra"); + std::fs::write(&path, kra()).unwrap(); + let mut h = Headless::trusted_local(); + let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + let warnings = opened["warnings"].to_string(); + assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}"); + let doc = &h.session.active().unwrap().doc; + let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Paper", "Inks"]); + let line = doc.layers[1].children().unwrap()[0].clone(); + assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply)); + let flat = photocraft_compose::flatten(doc); + assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]); + + // Read-only source: the file itself is never overwritten as Krita. + assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err()); + exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5})); + let ora = dir.join("art.ora"); + h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap(); + let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document; + assert_eq!(back.layers.len(), 2); + assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 2757df5696..c31d2d8afb 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -117,8 +117,9 @@ pub(crate) fn list_images(dir: &str) -> Result> { /// Extensions the batch commands pick up from a folder. #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ - "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", - "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", + "pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", + "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", + "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/kra.rs new file mode 100644 index 0000000000..8bdf10531c --- /dev/null +++ b/crates/engine/tests/kra.rs @@ -0,0 +1,53 @@ +//! Krita documents opened through the session: read-only source, 16-bit layers, editable +//! layers with history, and saving as OpenRaster or native. The files come from +//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with +//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test kra -- --ignored --nocapture`. +use photocraft_engine::Session; +use serde_json::json; + +fn open(path: &std::path::Path) -> Session { + let mut session = Session::new(); + let name = path.to_string_lossy().into_owned(); + photocraft_engine::file_cmds::open_bytes_as(&mut session, &name, &std::fs::read(path).unwrap(), None, Some(name.clone())).unwrap(); + session +} + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_documents_open_read_only_and_stay_editable() { + let dir = std::path::PathBuf::from(std::env::var("KRITA_TEST_DIR").expect("set KRITA_TEST_DIR")); + let path = dir.join("depth-rgba-u16.kra"); + let mut session = open(&path); + let active = session.active().unwrap(); + assert!(active.source_read_only && active.path.is_none()); + assert_eq!(active.doc.depth, photocraft_color::SampleType::U16); + assert!(session.execute("file.save", json!({})).is_err()); + + // Krita's merged image is the composite (the corpus files carry no ICC profile, so both + // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file. + let ours = photocraft_compose::flatten(&session.active().unwrap().doc); + let zip_bytes = std::fs::read(&path).unwrap(); + let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap(); + let merged = photocraft_io::import("m.png", &zip.read_by_name("mergedimage.png", 1 << 28).unwrap()).unwrap().document; + let theirs = photocraft_compose::flatten(&merged); + let worst = ours.px.iter().zip(&theirs.px).map(|(a, b)| (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold(0.0f32, f32::max)).fold(0.0, f32::max); + assert!(worst <= 3.0 / 255.0, "{}", worst * 255.0); + + // Imported Krita layers take the ordinary commands and history. + let mut session = open(&dir.join("layers-opacity-visibility.kra")); + let before = session.active().unwrap().doc.clone(); + let id = before.layers.last().unwrap().id; + session.execute("layer.setProps", json!({"layer":id.0,"name":"Edited","opacity":0.25,"blend":"Screen"})).unwrap(); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().blend, photocraft_color::BlendMode::Screen); + session.execute("layer.select", json!({"layer":id.0})).unwrap(); + session.execute("paint.stroke", json!({"points":[[2,2],[30,30]],"size":6,"hardness":1.0,"color":"#00ff00"})).unwrap(); + assert!(session.undo() && session.undo()); + assert_eq!(session.active().unwrap().doc.layers, before.layers); + // ...and save as OpenRaster or native, never back to .kra. + let doc = &session.active().unwrap().doc; + assert!(photocraft_io::export(doc, "x.kra", &Default::default()).is_err()); + let ora = photocraft_io::export(doc, "x.ora", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.ora", &ora.bytes).unwrap().document.layers.len(), doc.layers.len()); + let native = photocraft_io::export(doc, "x.pcraft", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.pcraft", &native.bytes).unwrap().document.layers, doc.layers); +} diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index a3af8b119c..64e4facf61 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -1,108 +1,342 @@ -//! Krita .kra import: the merged PNG is an editable raster layer, not a replacement -//! for Krita's native layer tree. Never write this document back to the .kra source. -//! The ZIP reader checks entry bounds, sizes and CRC before the PNG decoder runs. +//! Krita (`.kra`, `.krz`) import. Layout observed from files Krita writes (no Krita code used): +//! a ZIP whose first, stored entry is `mimetype` = `application/x-krita`, a `maindoc.xml` node +//! tree, one tiled pixel file per paint layer and mask under `/layers/`, the +//! image's ICC profile in `/annotations/icc`, and Krita's rendering in +//! `mergedimage.png`. +//! +//! Tiled pixel files are a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, +//! `PIXELSIZE`, `DATA `) followed by, per tile, a `x,y,LZF,` line and that many +//! bytes: a flag (1 = LZF-compressed, 0 = raw) and the tile's pixels stored as byte planes +//! (every pixel's first byte, then every pixel's second byte, ...). RGB pixels are in +//! Krita's BGRA order, 16-bit samples little-endian. +//! +//! Imported: paint layers (8 and 16-bit RGBA and gray), groups (isolated and pass-through), +//! names, visibility, opacity, blend modes, locks, layer offsets and transparency masks. +//! Every other node kind (vector, filter, fill, file and clone layers, filter, transform and +//! colorize masks) is left out with a warning; Krita's merged image still shows their look. +//! Documents in a colour space we don't read open as Krita's merged image, flagged as a preview. -use photocraft_codecs::{self as codecs, DecodeOptions, Format}; +#[cfg(test)] +mod tests; +mod tiles; + +use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; use photocraft_format::zip::ZipReader; -use photocraft_raster::Interrupt; +use photocraft_geom::Size; +use photocraft_raster::{Interrupt, Surface}; +use crate::ora::{MAX_NODES, MAX_PIXEL_BYTES, MAX_SIDE, Warnings, krita_blend}; use crate::{ImportResult, IoError}; +use tiles::{Space, TileFile}; -const MIMETYPE: &[u8] = b"application/x-krita"; -const MAX_ENTRY: usize = 512 << 20; -const MAX_PIXELS_BYTES: u64 = 1 << 30; +type Result = std::result::Result; + +/// The `mimetype` entry of a Krita archive. +pub(crate) const MIMETYPE: &[u8] = b"application/x-krita"; +pub(crate) const EXTENSIONS: [&str; 2] = ["kra", "krz"]; +const MAX_MAINDOC: usize = 32 << 20; +const MAX_LAYER_FILE: usize = 1 << 30; fn invalid(message: impl Into) -> IoError { IoError::Kra(message.into()) } -/// Krita writes its mimetype as the first, uncompressed ZIP entry. +/// `true` for a Krita archive (by its `mimetype` entry). pub(crate) fn is_kra(bytes: &[u8]) -> bool { crate::ora::zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE) } -/// Import Krita's official merged preview as a flattened, editable PhotoCraft document. -/// Do not silently pretend this imported a layered or lossless Krita document. -pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let archive = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; - let mime = archive.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; +fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> { + node.attribute(name).map(str::trim) +} + +fn number(node: roxmltree::Node<'_, '_>, name: &str, default: f64) -> Result { + match attr(node, name) { + None | Some("") => Ok(default), + Some(v) => v.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))), + } +} + +fn offset(node: roxmltree::Node<'_, '_>, name: &str) -> Result { + let v = number(node, name, 0.0)?.round(); + if v.abs() > f64::from(MAX_SIDE) * 4.0 { + return Err(invalid(format!("{name} offset {v} is out of range"))); + } + Ok(v as i32) +} + +fn truthy(node: roxmltree::Node<'_, '_>, name: &str) -> bool { + matches!(attr(node, name), Some("1" | "true")) +} + +/// Child elements named `tag` inside the node's `` element (`layers` or `masks`). +fn children<'a, 'i>(node: roxmltree::Node<'a, 'i>, container: &str, tag: &'static str) -> impl Iterator> { + node.children().filter(move |c| c.has_tag_name(container)).flat_map(move |c| c.children().filter(move |n| n.has_tag_name(tag))) +} + +struct Reader<'a, 'c> { + zip: ZipReader<'a>, + ctl: &'c Interrupt<'c>, + /// `/layers/`. + dir: String, + /// The document's pixel format (layers in another supported space are converted to it). + fmt: PixelFormat, + nodes: usize, + budget: usize, + warnings: Warnings, +} + +impl Reader<'_, '_> { + fn file(&self, name: &str) -> Result>> { + let path = format!("{}{name}", self.dir); + match self.zip.find(&path) { + None => Ok(None), + Some(e) => self.zip.read(e, MAX_LAYER_FILE).map(Some).map_err(|err| invalid(format!("{path}: {err}"))), + } + } + + /// The default pixel Krita stores next to a tiled file, decoded to `space` samples. + fn default_pixel(&self, name: &str, space: Space) -> Result>> { + let Some(bytes) = self.file(&format!("{name}.defaultpixel"))? else { return Ok(None) }; + if bytes.len() != space.pixel_size() { + return Err(invalid(format!("{name}.defaultpixel has {} bytes, expected {}", bytes.len(), space.pixel_size()))); + } + Ok(Some(space.normalised(&bytes))) + } + + /// A tiled pixel file in `space` as a surface in `fmt`, offset by (`dx`, `dy`). + fn surface(&mut self, name: &str, space: Space, fmt: PixelFormat, dx: i32, dy: i32) -> Result> { + let Some(bytes) = self.file(name)? else { return Ok(None) }; + let file = TileFile::parse(&bytes)?; + if file.pixel_size != space.pixel_size() { + return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size))); + } + let cost = file.decoded_bytes(fmt.bytes_per_pixel())?; + self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?; + let mut surface = match self.default_pixel(name, space)? { + Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)), + _ => Surface::new(fmt), + }; + file.decode_into(&mut surface, space, dx, dy, self.ctl)?; + surface.prune(); + Ok(Some(surface)) + } + + fn props(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer) -> Result<()> { + layer.name = attr(node, "name").filter(|n| !n.is_empty()).map_or_else(|| format!("Layer {}", self.nodes), str::to_string); + layer.visible = attr(node, "visible") != Some("0"); + layer.opacity = (number(node, "opacity", 255.0)? / 255.0).clamp(0.0, 1.0) as f32; + if truthy(node, "locked") { + layer.locks.all = true; + } + let op = attr(node, "compositeop").unwrap_or("normal"); + layer.blend = match krita_blend(op) { + Some((mode, approx)) => { + if approx { + self.warnings.note(&format!("Krita's {op} blend mode is approximated by {}", mode.label())); + } + mode + } + None => { + self.warnings.layer(&layer.name, &format!("Krita's {op} blend mode is not supported; it opens as Normal")); + BlendMode::Normal + } + }; + if attr(node, "channelflags").is_some_and(|f| f.contains('0')) { + self.warnings.layer(&layer.name, "channel locks (Krita's per-channel blending) are ignored"); + } + if truthy(node, "inheritalpha") { + self.warnings.layer(&layer.name, "Inherit Alpha is not supported; these layers blend normally"); + } + if attr(node, "keyframes").is_some_and(|k| !k.is_empty()) { + self.warnings.layer(&layer.name, "animated layers open with the frame Krita saved in the layer"); + } + Ok(()) + } + + /// The node's transparency masks, combined into one layer mask. + fn masks(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer, dx: i32, dy: i32) -> Result<()> { + for mask in children(node, "masks", "mask") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + let kind = attr(mask, "nodetype").unwrap_or(""); + match kind { + "transparencymask" => {} + // A layer's local selection: not part of its look. + "selectionmask" => continue, + other => { + let what = match other { + "filtermask" => "filter masks", + "transformmask" => "transform masks", + "colorizemask" => "colorize masks", + _ => "unknown masks", + }; + self.warnings.layer(&layer.name, &format!("{what} are not supported and were left out")); + continue; + } + } + if layer.mask.is_some() { + self.warnings.layer(&layer.name, "only the first transparency mask of a layer is kept"); + continue; + } + let Some(file) = attr(mask, "filename") else { continue }; + let (mx, my) = (dx.saturating_add(offset(mask, "x")?), dy.saturating_add(offset(mask, "y")?)); + match self.surface(&format!("{file}.pixelselection"), Space::Alpha8, PixelFormat::GRAY8, mx, my)? { + Some(surface) => { + layer.mask = Some(LayerMask { surface, enabled: attr(mask, "visible") != Some("0"), ..LayerMask::reveal_all() }); + } + None => self.warnings.layer(&layer.name, "a transparency mask's pixels are missing from the file"), + } + } + Ok(()) + } + + /// The `` of `node`, bottom-to-top (maindoc.xml lists the topmost first). + fn layers(&mut self, node: roxmltree::Node<'_, '_>, depth: usize, dx: i32, dy: i32) -> Result> { + if depth > photocraft_doc::MAX_GROUP_DEPTH { + return Err(IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH))); + } + let mut out = Vec::new(); + for child in children(node, "layers", "layer") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(invalid(format!("more than {MAX_NODES} layers"))); + } + let (x, y) = (dx.saturating_add(offset(child, "x")?), dy.saturating_add(offset(child, "y")?)); + let kind = attr(child, "nodetype").unwrap_or(""); + let mut layer = match kind { + "paintlayer" => { + let mut layer = Layer::raster("", self.fmt); + self.props(child, &mut layer)?; + let space = attr(child, "colorspacename").and_then(Space::from_name); + match (space, attr(child, "filename")) { + (Some(space), Some(file)) => match self.surface(file, space, self.fmt, x, y)? { + Some(s) => layer.content = LayerContent::Raster(s), + None => self.warnings.layer(&layer.name, "the layer's pixels are missing from the file; it opens empty"), + }, + (None, _) => { + let cs = attr(child, "colorspacename").unwrap_or("?"); + self.warnings.layer(&layer.name, &format!("layers in Krita's {cs} colour space are not supported and were left out")); + continue; + } + (_, None) => self.warnings.layer(&layer.name, "the layer names no pixel file; it opens empty"), + } + layer + } + "grouplayer" => { + let mut layer = Layer::group("", Vec::new()); + self.props(child, &mut layer)?; + if truthy(child, "passthrough") { + if layer.blend != BlendMode::Normal { + self.warnings.layer(&layer.name, "a pass-through group's blend mode is ignored, as in Krita"); + } + layer.blend = BlendMode::PassThrough; + } + // A group's own x/y is not applied to its children: Krita moves a group by + // moving their pixels, and its merged image ignores the group offset. + let kids = self.layers(child, depth + 1, dx, dy)?; + if let LayerContent::Group(g) = &mut layer.content { + g.children = kids; + g.expanded = !truthy(child, "collapsed"); + } + layer + } + other => { + let name = attr(child, "name").unwrap_or(other).to_string(); + let what = match other { + "shapelayer" => "vector layers", + "adjustmentlayer" => "filter layers", + "generatorlayer" => "fill layers", + "filelayer" => "file layers", + "clonelayer" => "clone layers", + _ => "unknown layer kinds", + }; + self.warnings.layer(&name, &format!("Krita {what} are not supported yet and were left out")); + continue; + } + }; + self.masks(child, &mut layer, x, y)?; + out.push(layer); + } + out.reverse(); + Ok(out) + } +} + +/// Krita's merged image as a one-layer stand-in, for documents we can't read layer by layer. +fn preview(name: &str, zip: &ZipReader<'_>, why: String) -> Result { + let png = zip.read_by_name("mergedimage.png", MAX_LAYER_FILE).map_err(|e| invalid(format!("{why}, and the merged image can't be read: {e}")))?; + let mut r = crate::flat::import_flat(name, &png)?; + r.document.name = name.to_string(); + r.warnings.insert(0, format!("{why}; the file opens as Krita's merged image, without its layers")); + r.source_read_only = true; + r.preview_only = true; + Ok(r) +} + +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + let zip = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; + let mime = zip.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; if mime.trim_ascii() != MIMETYPE { return Err(invalid("the archive's mimetype is not application/x-krita")); } - ctl.progress(0.10); - let merged = archive.read_by_name("mergedimage.png", MAX_ENTRY).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - if codecs::detect(&merged) != Some(Format::Png) { - return Err(invalid("mergedimage.png is not a PNG image")); - } - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut opts = DecodeOptions::default(); - opts.limits.max_width = crate::ora::MAX_SIDE; - opts.limits.max_height = crate::ora::MAX_SIDE; - opts.limits.max_alloc = MAX_PIXELS_BYTES; - let image = codecs::decode_with(&merged, &opts).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut result = crate::flat::image_to_document(name, &image)?; - result.source_read_only = true; - result.warnings.push("Krita layers, masks and editable document structure were not imported; only mergedimage.png is available".into()); - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType}; - use photocraft_format::zip::ZipWriter; - - fn png() -> Vec { - let image = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap(); - codecs::encode(&image, Format::Png, &EncodeOptions::default()).unwrap() - } - - fn archive(mime: &[u8], with_merged: bool) -> Vec { - let mut zip = ZipWriter::new(); - zip.add("mimetype", mime).unwrap(); - zip.add("maindoc.xml", b"").unwrap(); - if with_merged { - zip.add("mergedimage.png", &png()).unwrap(); + let xml = zip.read_by_name("maindoc.xml", MAX_MAINDOC).map_err(|e| invalid(e.to_string()))?; + let xml = std::str::from_utf8(&xml).map_err(|_| invalid("maindoc.xml is not UTF-8"))?; + // maindoc.xml declares Krita's DTD by public id; roxmltree only reads internal subsets and + // guards entity expansion. + let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let tree = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?; + let image = tree.root_element().children().find(|n| n.has_tag_name("IMAGE")).ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?; + let side = |a: &str| -> Result { + let v = number(image, a, 0.0)?; + if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 { + return Err(invalid(format!("image {a} {v} must be a whole number of pixels from 1 to {MAX_SIDE}"))); } - zip.finish().unwrap() + Ok(v as u32) + }; + let (w, h) = (side("width")?, side("height")?); + if (w as usize).saturating_mul(h as usize).saturating_mul(8) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); } - - #[test] - fn imports_krita_composite_without_claiming_to_preserve_layers() { - let bytes = archive(MIMETYPE, true); - assert!(is_kra(&bytes)); - // The mimetype is authoritative even without a .kra extension. - for name in ["scene.kra", "download"] { - let r = crate::import(name, &bytes).unwrap(); - assert_eq!((r.document.size.width, r.document.size.height), (2, 1)); - assert_eq!(r.document.name, name); - assert_eq!(r.document.layers.len(), 1); - assert_eq!(r.document.layers[0].name, "Layer 0", "alpha-bearing composite"); - let pixels = r.document.layers[0].surface().unwrap(); - assert!(pixels.rgba(0, 0)[0] > 0.99); - assert!(pixels.rgba(1, 0)[2] > 0.99); - assert!((pixels.rgba(1, 0)[3] - 128.0 / 255.0).abs() < 0.01); - assert!(r.source_read_only, ".kra must not be overwritten by a flat save"); - assert!(!r.preview_only, "the flattened image is editable"); - assert!(r.warnings.iter().any(|w| w.contains("layers") && w.contains("not imported"))); + let cs = attr(image, "colorspacename").unwrap_or(""); + let Some(space) = Space::from_name(cs) else { + return preview(name, &zip, format!("Krita's {cs} colour space is not supported yet")); + }; + let (mode, depth) = space.model(); + let image_name = attr(image, "name").unwrap_or(""); + let mut reader = Reader { + zip, + ctl, + dir: format!("{image_name}/layers/"), + fmt: PixelFormat::new(mode, depth, true), + nodes: 0, + budget: MAX_PIXEL_BYTES, + warnings: Warnings::default(), + }; + let layers = reader.layers(image, 1, 0, 0)?; + ctl.progress(0.9); + let mut document = Document::new(name, Size::new(w, h), mode, depth); + document.layers = layers; + let icc = format!("{image_name}/annotations/icc"); + if let Some(e) = reader.zip.find(&icc) { + let profile = reader.zip.read(e, 16 << 20).map_err(|err| invalid(format!("{icc}: {err}")))?; + if !profile.is_empty() { + document.icc_profile = Some(std::sync::Arc::new(profile)); } } - - #[test] - fn malformed_or_non_krita_archives_do_not_open_as_krita() { - let wrong = archive(b"image/openraster", true); - assert!(!is_kra(&wrong)); - assert!(import("wrong.kra", &wrong, &Interrupt::NONE).is_err()); - assert!(import("missing.kra", &archive(MIMETYPE, false), &Interrupt::NONE).is_err()); - assert!(import("broken.kra", b"not a zip", &Interrupt::NONE).is_err()); + let (xres, yres) = (number(image, "x-res", 72.0)?, number(image, "y-res", 72.0)?); + if xres > 0.0 && xres < 1.0e6 { + document.resolution_dpi = xres as f32; + if let Some(w) = crate::unequal_resolution_warning(xres, yres) { + reader.warnings.note(&w); + } } - - #[test] - fn a_png_with_a_misleading_kra_extension_remains_a_png() { - let r = crate::import("renamed.kra", &png()).unwrap(); - assert_eq!(r.document.size.width, 2); - assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + if document.layers.is_empty() { + reader.warnings.note("no layers of the Krita document could be imported"); } + if image.children().any(|n| n.has_tag_name("assistants") || n.has_tag_name("ReferenceImages")) { + reader.warnings.note("drawing assistants and reference images were left out"); + } + Ok(ImportResult { document, warnings: reader.warnings.finish(), source_read_only: true, preview_only: false }) } diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs new file mode 100644 index 0000000000..4316a74c10 --- /dev/null +++ b/crates/io/src/kra/tests.rs @@ -0,0 +1,287 @@ +use super::tiles::{Space, lzf_decompress}; +use super::*; +use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_format::zip::ZipWriter; +use photocraft_geom::Rect; + +const T: usize = 4; // small tiles keep the fixtures short + +/// LZF with literal runs only (valid liblzf output). +fn lzf_literal(data: &[u8]) -> Vec { + let mut out = Vec::new(); + for run in data.chunks(32) { + out.push((run.len() - 1) as u8); + out.extend_from_slice(run); + } + out +} + +/// A tiled pixel file from (x, y, interleaved Krita pixels) tiles; odd tiles LZF, even raw. +fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { + let mut out = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE {pixel_size}\nDATA {}\n", tiles.len()).into_bytes(); + for (i, (x, y, px)) in tiles.iter().enumerate() { + let n = T * T; + let mut planar = vec![0; n * pixel_size]; + for p in 0..n { + for k in 0..pixel_size { + planar[k * n + p] = px[p * pixel_size + k]; + } + } + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); + out.extend(body); + } + out +} + +fn archive(maindoc: &str, files: &[(&str, Vec)]) -> Vec { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + for (name, data) in files { + z.add(name, data).unwrap(); + } + z.finish().unwrap() +} + +fn maindoc(cs: &str, layers: &str) -> String { + format!( + "\n\n\ + {layers}" + ) +} + +/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`. +fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec { + (0..T * T).flat_map(f).collect() +} + +fn layer<'a>(d: &'a Document, name: &str) -> &'a Layer { + fn find<'a>(ls: &'a [Layer], name: &str) -> Option<&'a Layer> { + ls.iter().find_map(|l| if l.name == name { Some(l) } else { l.children().and_then(|c| find(c, name)) }) + } + find(&d.layers, name).unwrap_or_else(|| panic!("no layer {name}")) +} + +#[test] +fn reads_layers_groups_masks_and_properties() { + let doc = maindoc( + "RGBA", + r#" + + + + + + + "#, + ); + let red = bgra(|p| [0, 0, 255, (p * 16) as u8]); + let files = [ + ("img/layers/layer2", tile_file(4, &[(0, 0, red.clone()), (4, 0, bgra(|_| [10, 20, 30, 255]))])), + ("img/layers/layer2.defaultpixel", vec![0; 4]), + ("img/layers/layer3", tile_file(4, &[(-4, 4, bgra(|_| [1, 2, 3, 200]))])), + ("img/layers/mask4.pixelselection", tile_file(1, &[(-4, 4, (0..T * T).map(|p| (p * 10) as u8).collect())])), + ("img/layers/mask4.pixelselection.defaultpixel", vec![0]), + ("img/annotations/icc", b"not really a profile".to_vec()), + ]; + let r = crate::import("art.kra", &archive(&doc, &files)).unwrap(); + let d = &r.document; + assert!(r.source_read_only && !r.preview_only); + assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (12, 8, ColorMode::Rgb, SampleType::U8)); + assert_eq!(d.resolution_dpi, 300.0); + assert!(d.icc_profile.is_some()); + // Bottom-to-top: the unsupported nodes are left out. + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Masked", "Through"]); + let through = layer(d, "Through"); + assert_eq!(through.blend, BlendMode::PassThrough); + assert!(matches!(&through.content, LayerContent::Group(g) if !g.expanded)); + let inner = layer(d, "Inner"); + assert_eq!((inner.blend, inner.locks.all), (BlendMode::Screen, true)); + assert!((inner.opacity - 128.0 / 255.0).abs() < 1e-6); + // The layer's offset (1, 0) moves its tiles; the group's (2, 1) does not, as in Krita. + let s = inner.surface().unwrap(); + assert_eq!(s.content_bounds(), Rect::from_xywh(1, 0, 8, 4)); + let px = s.to_interleaved(Rect::from_xywh(1, 0, 2, 1)); + assert_eq!(px, [255, 0, 0, 0, 255, 0, 0, 16]); + assert_eq!(s.to_interleaved(Rect::from_xywh(5, 0, 1, 1)), [30, 20, 10, 255]); + let masked = layer(d, "Masked"); + assert!(!masked.visible); + assert_eq!(masked.blend, BlendMode::Difference); + let mask = masked.mask.as_ref().unwrap(); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(-4, 4, 2, 1)), [0, 10]); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(5, 5, 1, 1)), [0], "outside the tiles the default (hide) applies"); + let all = r.warnings.join("\n"); + for needle in ["vector layers", "LABA", "pass-through group's blend mode"] { + assert!(all.contains(needle), "{needle} not in {all}"); + } +} + +#[test] +fn sixteen_bit_and_gray_spaces_convert_exactly() { + let px16: Vec = (0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::>()).collect(); + let doc = maindoc( + "RGBA16", + r#""#, + ); + let gray: Vec = (0..T * T).flat_map(|p| [p as u8 * 10, 255]).collect(); + let r = + crate::import("a.kra", &archive(&doc, &[("img/layers/l", tile_file(8, &[(0, 0, px16)])), ("img/layers/g", tile_file(2, &[(0, 0, gray)]))])).unwrap(); + let d = &r.document; + assert_eq!(d.depth, SampleType::U16); + let s = layer(d, "L").surface().unwrap(); + assert_eq!(s.format(), PixelFormat::RGBA16); + let got: Vec = s.to_interleaved(Rect::from_xywh(1, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + // Krita's B, G, R, A becomes R, G, B, A. + assert_eq!(got, [3000, 2000, 1001, 65535]); + // A gray layer in an RGB document is converted. + let g = layer(d, "G").surface().unwrap(); + assert_eq!(g.format(), PixelFormat::RGBA16); + let v: Vec = g.to_interleaved(Rect::from_xywh(2, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [20 * 257, 20 * 257, 20 * 257, 65535]); + + let doc = maindoc("GRAYAU16", r#""#); + let g16: Vec = (0..T * T).flat_map(|p| [(p as u16 * 4000).to_le_bytes(), 40000u16.to_le_bytes()].concat()).collect(); + let r = crate::import("g.kra", &archive(&doc, &[("img/layers/g", tile_file(4, &[(0, 0, g16)]))])).unwrap(); + assert_eq!((r.document.mode, r.document.depth), (ColorMode::Grayscale, SampleType::U16)); + let s = layer(&r.document, "G16").surface().unwrap(); + let v: Vec = s.to_interleaved(Rect::from_xywh(3, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [12000, 40000]); +} + +#[test] +fn a_nonzero_default_pixel_fills_untouched_areas() { + let doc = maindoc("RGBA", r#""#); + let files = [("img/layers/l", tile_file(4, &[])), ("img/layers/l.defaultpixel", vec![255, 0, 0, 255])]; + let r = crate::import("a.kra", &archive(&doc, &files)).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); +} + +fn merged_png() -> Vec { + let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() +} + +#[test] +fn unsupported_colour_spaces_open_as_the_merged_image() { + let doc = maindoc("CMYKA", ""); + let r = crate::import("c.kra", &archive(&doc, &[("mergedimage.png", merged_png())])).unwrap(); + assert!(r.preview_only && r.source_read_only); + assert_eq!(r.document.layers.len(), 1); + assert!(r.warnings.first().is_some_and(|w| w.contains("CMYKA") && w.contains("merged image")), "{:?}", r.warnings); + assert!(crate::import("c.kra", &archive(&doc, &[])).is_err(), "no merged image to fall back to"); +} + +#[test] +fn export_is_refused() { + let d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + for name in ["x.kra", "x.KRZ"] { + assert!(matches!(crate::export(&d, name, &Default::default()), Err(IoError::Unsupported(_))), "{name}"); + } +} + +#[test] +fn lzf_decodes_literals_and_overlapping_references() { + // "abc" then a 6-byte back-reference 3 back: abcabcabc. + assert_eq!(lzf_decompress(&[2, b'a', b'b', b'c', 0x80, 2], 9).unwrap(), b"abcabcabc"); + // A long back-reference (len 7 + extra) over a single byte: run-length. + assert_eq!(lzf_decompress(&[0, b'x', 0xe0, 3, 0], 13).unwrap(), vec![b'x'; 13]); + for bad in [&[0x20, 5][..], &[5, 1, 2], &[0xe0], &[2, 1, 2, 3, 0x20]] { + assert!(lzf_decompress(bad, 64).is_err(), "{bad:?}"); + } + assert!(lzf_decompress(&[2, 1, 2, 3], 2).is_err(), "overrun"); + assert!(lzf_decompress(&[2, 1, 2, 3], 4).is_err(), "short"); +} + +#[test] +fn malformed_files_fail_cleanly() { + let one = |cs: &str, data: Vec| { + archive(&maindoc(cs, r#""#), &[("img/layers/l", data)]) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("not a zip", b"nope".to_vec()), + ("no maindoc", { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.finish().unwrap() + }), + ("bad xml", archive("", &[])), + ("zero size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"0\""), &[])), + ("huge", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300000\"").replace("height=\"8\"", "height=\"300000\""), &[])), + ("version 1", one("RGBA", b"VERSION 1\n".to_vec())), + ("no header", one("RGBA", b"garbage".to_vec())), + ("big tiles", one("RGBA", b"VERSION 2\nTILEWIDTH 99999\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 0\n".to_vec())), + ("pixel size", one("RGBA", tile_file(2, &[]))), + ("too many tiles", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 999999\n").into_bytes())), + ("bad tile line", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,ZIP,4\n").into_bytes())), + ("truncated tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,400\n\0").into_bytes())), + ("raw size", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,3\n\0ab").into_bytes())), + ("flag", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,1\n\x07").into_bytes())), + ("far tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n2000000000,0,LZF,1\n\x01").into_bytes())), + ("bad opacity", archive(&maindoc("RGBA", r#""#), &[])), + ]; + for (what, bytes) in cases { + assert!(crate::import("x.kra", &bytes).is_err(), "{what}"); + } + let deep = maindoc("RGBA", &format!("{}{}", r#""#.repeat(150), "".repeat(150))); + assert!(matches!(crate::import("x.kra", &archive(&deep, &[])), Err(IoError::Unsupported(_)))); +} + +#[test] +fn truncated_and_corrupted_files_never_panic() { + let doc = maindoc( + "RGBA", + r#""#, + ); + let bytes = archive( + &doc, + &[ + ("img/layers/l", tile_file(4, &[(0, 0, bgra(|p| [p as u8; 4])), (4, 4, bgra(|_| [9; 4]))])), + ("img/layers/m.pixelselection", tile_file(1, &[(0, 0, vec![7; T * T])])), + ("mergedimage.png", merged_png()), + ], + ); + assert!(crate::import("ok.kra", &bytes).is_ok()); + for cut in (0..bytes.len()).step_by((bytes.len() / 300).max(1)) { + let _ = crate::import("x.kra", bytes.get(..cut).unwrap_or_default()); + } + let mut state = 0x1234_5678_u32; + for _ in 0..400 { + let mut b = bytes.clone(); + for _ in 0..3 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let i = state as usize % b.len(); + b[i] ^= (state >> 24) as u8 | 1; + } + let _ = crate::import("x.kra", &b); + } +} + +#[test] +fn space_names() { + assert_eq!(Space::from_name("RGBA"), Some(Space::Rgba8)); + assert_eq!(Space::from_name("RGBAF32"), None); + assert!(is_kra(&archive("", &[]))); +} + +#[test] +fn a_png_with_a_misleading_kra_extension_remains_a_png() { + for name in ["renamed.kra", "renamed.krz"] { + let r = crate::import(name, &merged_png()).unwrap(); + assert_eq!(r.document.size.width, 12); + assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + } +} + +#[test] +fn the_mimetype_wins_over_the_extension() { + let bytes = archive(&maindoc("RGBA", ""), &[]); + assert!(is_kra(&bytes)); + assert!(crate::import("download", &bytes).is_ok_and(|r| r.source_read_only)); +} diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs new file mode 100644 index 0000000000..b369341b99 --- /dev/null +++ b/crates/io/src/kra/tiles.rs @@ -0,0 +1,259 @@ +//! Krita's tiled pixel files and the LZF compression inside them. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; + +use super::{Result, invalid}; +use crate::IoError; + +/// The pixel layouts we read, by Krita colour-space id. Samples are straight (unpremultiplied). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Space { + /// `RGBA`: B, G, R, A bytes. + Rgba8, + /// `RGBA16`: B, G, R, A as little-endian u16. + Rgba16, + /// `GRAYA`: gray, alpha bytes. + Gray8, + /// `GRAYAU16`: gray, alpha as little-endian u16. + Gray16, + /// Masks and selections: one byte. + Alpha8, +} + +impl Space { + pub(crate) fn from_name(name: &str) -> Option { + Some(match name { + "RGBA" => Space::Rgba8, + "RGBA16" => Space::Rgba16, + "GRAYA" => Space::Gray8, + "GRAYAU16" => Space::Gray16, + _ => return None, + }) + } + pub(crate) fn pixel_size(self) -> usize { + match self { + Space::Rgba8 => 4, + Space::Rgba16 => 8, + Space::Gray8 => 2, + Space::Gray16 => 4, + Space::Alpha8 => 1, + } + } + /// The document colour mode and depth for an image in this space. + pub(crate) fn model(self) -> (ColorMode, SampleType) { + match self { + Space::Rgba8 => (ColorMode::Rgb, SampleType::U8), + Space::Rgba16 => (ColorMode::Rgb, SampleType::U16), + Space::Gray8 | Space::Alpha8 => (ColorMode::Grayscale, SampleType::U8), + Space::Gray16 => (ColorMode::Grayscale, SampleType::U16), + } + } + /// The surface format whose bytes [`Self::convert`] writes directly. + fn native(self) -> PixelFormat { + match self { + Space::Alpha8 => PixelFormat::GRAY8, + s => { + let (mode, depth) = s.model(); + PixelFormat::new(mode, depth, true) + } + } + } + /// One interleaved Krita pixel as straight RGBA (0..1). + fn rgba(self, px: &[u8]) -> [f32; 4] { + let b = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0; + let w = |i: usize| f32::from(u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)])) / 65535.0; + match self { + Space::Rgba8 => [b(2), b(1), b(0), b(3)], + Space::Rgba16 => [w(4), w(2), w(0), w(6)], + Space::Gray8 => [b(0), b(0), b(0), b(1)], + Space::Gray16 => [w(0), w(0), w(0), w(2)], + Space::Alpha8 => [b(0), b(0), b(0), 1.0], + } + } + /// A default pixel's samples (Krita order, 0..1), for [`Self::to_format`]. + pub(crate) fn normalised(self, px: &[u8]) -> Vec { + self.rgba(px).to_vec() + } + /// RGBA samples from [`Self::normalised`] as `fmt`'s channels. + pub(crate) fn to_format(self, rgba: &[f32], fmt: PixelFormat) -> Vec { + let p = [0, 1, 2, 3].map(|i| rgba.get(i).copied().unwrap_or(0.0)); + photocraft_raster::from_rgba(&fmt, p) + } + /// Interleaved Krita pixels as encoded `fmt` bytes. + fn convert(self, src: &[u8], fmt: PixelFormat, out: &mut Vec) { + out.clear(); + let ps = self.pixel_size(); + if fmt == self.native() { + for px in src.chunks_exact(ps) { + match self { + Space::Rgba8 => out.extend([2, 1, 0, 3].map(|i| px.get(i).copied().unwrap_or(0))), + Space::Rgba16 => { + for i in [4, 2, 0, 6] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray16 => { + for i in [0, 2] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray8 | Space::Alpha8 => out.extend_from_slice(px), + } + } + return; + } + let mut enc = vec![0; fmt.bytes_per_pixel()]; + for px in src.chunks_exact(ps) { + let v = photocraft_raster::from_rgba(&fmt, self.rgba(px)); + photocraft_raster::encode_pixel(&fmt, &v, &mut enc); + out.extend_from_slice(&enc); + } + } +} + +/// LZF decompression (the liblzf format) into exactly `expected` bytes. +pub(crate) fn lzf_decompress(src: &[u8], expected: usize) -> Result> { + let bad = || invalid("corrupt LZF tile data"); + let mut out = Vec::with_capacity(expected); + let mut ip = 0; + while ip < src.len() { + let ctrl = usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + if ctrl < 32 { + let len = ctrl + 1; + let lit = src.get(ip..ip + len).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + out.extend_from_slice(lit); + ip += len; + } else { + let mut len = ctrl >> 5; + if len == 7 { + len += usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + } + len += 2; + let back = ((ctrl & 0x1f) << 8) + usize::from(*src.get(ip).ok_or_else(bad)?) + 1; + ip += 1; + let start = out.len().checked_sub(back).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + // Back-references may overlap the bytes they produce, so copy one at a time. + for i in start..start + len { + let b = *out.get(i).ok_or_else(bad)?; + out.push(b); + } + } + } + if out.len() != expected { + return Err(invalid(format!("LZF tile data decodes to {} bytes, expected {expected}", out.len()))); + } + Ok(out) +} + +/// A parsed tiled pixel file (`VERSION 2`), borrowing the tile payloads. +pub(crate) struct TileFile<'a> { + pub(crate) width: usize, + pub(crate) height: usize, + pub(crate) pixel_size: usize, + tiles: Vec<(i32, i32, &'a [u8])>, +} + +fn line<'a>(bytes: &'a [u8], pos: &mut usize) -> Result<&'a str> { + let rest = bytes.get(*pos..).ok_or_else(|| invalid("truncated tile file"))?; + let end = rest.iter().take(256).position(|b| *b == b'\n').ok_or_else(|| invalid("truncated or malformed tile file header"))?; + *pos += end + 1; + std::str::from_utf8(rest.get(..end).unwrap_or_default()).map_err(|_| invalid("tile file header is not text")) +} + +fn header_value(bytes: &[u8], pos: &mut usize, key: &str, max: usize) -> Result { + let l = line(bytes, pos)?; + let v = l.strip_prefix(key).map(str::trim).ok_or_else(|| invalid(format!("tile file: expected {key}, found `{l}`")))?; + v.parse::().ok().filter(|v| (1..=max).contains(v)).ok_or_else(|| invalid(format!("tile file: invalid {key} `{v}`"))) +} + +impl<'a> TileFile<'a> { + pub(crate) fn parse(bytes: &'a [u8]) -> Result> { + let mut pos = 0; + let version = line(bytes, &mut pos)?; + if version.trim() != "VERSION 2" { + return Err(IoError::Unsupported(format!("Krita tile file `{}` (only VERSION 2, Krita 2.0 and later)", version.trim()))); + } + let width = header_value(bytes, &mut pos, "TILEWIDTH", 1024)?; + let height = header_value(bytes, &mut pos, "TILEHEIGHT", 1024)?; + let pixel_size = header_value(bytes, &mut pos, "PIXELSIZE", 64)?; + let l = line(bytes, &mut pos)?; + let count = l + .strip_prefix("DATA") + .map(str::trim) + .and_then(|v| v.parse::().ok()) + .ok_or_else(|| invalid(format!("tile file: invalid DATA line `{l}`")))?; + // Every tile needs at least a header line and a flag byte. + if count > bytes.len() / 8 + 1 { + return Err(invalid("tile count exceeds the file size")); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let l = line(bytes, &mut pos)?; + let mut f = l.split(','); + let (x, y, method, len) = (f.next(), f.next(), f.next(), f.next()); + let coord = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.unsigned_abs() <= super::MAX_SIDE * 4); + let (Some(x), Some(y), Some("LZF"), Some(len), None) = + (coord(x), coord(y), method.map(str::trim), len.and_then(|v| v.trim().parse::().ok()), f.next()) + else { + return Err(invalid(format!("tile file: malformed tile header `{l}`"))); + }; + let data = bytes.get(pos..pos.checked_add(len).ok_or_else(|| invalid("tile size overflow"))?).ok_or_else(|| invalid("truncated tile data"))?; + pos += len; + tiles.push((x, y, data)); + } + Ok(TileFile { width, height, pixel_size, tiles }) + } + + /// Bytes the decoded tiles take at `bpp` bytes per pixel. + pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result { + self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow")) + } + + /// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`). + pub(crate) fn decode_into(&self, surface: &mut Surface, space: Space, dx: i32, dy: i32, ctl: &Interrupt<'_>) -> Result<()> { + let fmt = surface.format(); + let n = self.width * self.height; + let expected = n * self.pixel_size; + let mut interleaved = vec![0u8; expected]; + let mut out = Vec::new(); + 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(), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), + }; + // Byte planes: byte k of pixel i is at k * n + i. + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } + } + space.convert(&interleaved, fmt, &mut out); + let (x0, y0) = + (x.checked_add(dx).ok_or_else(|| invalid("tile offset overflow"))?, y.checked_add(dy).ok_or_else(|| invalid("tile offset overflow"))?); + let rect = Rect::from_xywh(x0, y0, self.width as u32, self.height as u32); + if rect.width() as usize != self.width || rect.height() as usize != self.height || out.len() != n * fmt.bytes_per_pixel() { + return Err(invalid("tile does not fit its position")); + } + surface.write_interleaved(rect, &out); + } + Ok(()) + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b6120eb088..87f40b12df 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,7 +18,8 @@ //! * Paint.NET PDN3 documents open as editable bitmap layers (import only). //! * OpenRaster (`.ora`) documents open and save with their layers, groups, blend modes, //! opacity and visibility; see `ora`. -//! * Krita (`.kra`) documents open as their merged composite; source layers are not imported. +//! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency +//! masks (import only); see `kra`. //! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively //! with no source save path, what isn't imported listed in the warnings; a file //! whose native data can't be read opens as its embedded preview; see `affinity`. @@ -82,12 +83,12 @@ pub enum IoError { /// Paint.NET document decode failure. #[error("PDN: {0}")] Pdn(String), + /// Krita document read failure. + #[error("Krita: {0}")] + Kra(String), /// OpenRaster read failure. #[error("OpenRaster: {0}")] Ora(String), - /// Krita document decode failure. - #[error("Krita: {0}")] - Kra(String), /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), @@ -288,9 +289,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if ora::is_ora(bytes) || has_extension(name, "ora") { return ora::import(name, bytes, ctl); } - // Krita files are ZIP containers carrying a merged PNG. A recognized raster signature - // still wins over a misleading .kra suffix (as with SVG imports). - if kra::is_kra(bytes) || (has_extension(name, "kra") && photocraft_codecs::detect(bytes).is_none()) { + // Krita files are ZIP containers. A recognized raster signature still wins over a + // misleading .kra suffix (as with SVG imports). + if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { return kra::import(name, bytes, ctl); } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { @@ -382,6 +383,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if affinity::EXTENSIONS.contains(&ext.as_str()) { return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into())); } + if kra::EXTENSIONS.contains(&ext.as_str()) { + return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into())); + } if ext == "ora" { return ora::export(doc); } diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/krita_corpus.rs index cafbe2c718..b0b3a01d63 100644 --- a/crates/io/tests/krita_corpus.rs +++ b/crates/io/tests/krita_corpus.rs @@ -1,4 +1,4 @@ -//! Krita-authored oracles: OpenRaster (`.ora`) files whose archives carry +//! Krita-authored oracles: OpenRaster (`.ora`) and Krita (`.kra`) files whose archives carry //! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are //! not bundled. Run with @@ -47,6 +47,10 @@ const KNOWN: &[(&str, f32, &str)] = &[ ("blend-lighter_color.ora", 0.02, "Krita weighs the channels differently when it picks the lighter colour; near-ties pick the other pixel"), ("blend-hard_mix_photoshop.ora", 0.02, "a channel sum of exactly 1 goes up in Photoshop's Hard Mix (ours) and down in Krita's"), ("depth-rgba-u16.ora", 1.0, "Krita blends this linear-light 16-bit document in linear light, but its ORA export stores sRGB 8-bit layers"), + ("blend-darker_color.kra", 0.02, "as for the .ora"), + ("blend-lighter_color.kra", 0.02, "as for the .ora"), + ("blend-hard_mix_photoshop.kra", 0.02, "as for the .ora"), + ("nodes-vector-unsupported.kra", 0.2, "vector layers are left out of .kra imports (reported); Krita's merged image has the shape"), ]; fn run(ext: &str, tolerance: f32) { @@ -85,3 +89,9 @@ fn run(ext: &str, tolerance: f32) { fn krita_ora_files_match_their_merged_image() { run("ora", 3.0 / 255.0); } + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_kra_files_match_their_merged_image() { + run("kra", 3.0 / 255.0); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc8d8bdf27..ea40171b9a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1259,7 +1259,8 @@ impl PhotocraftApp { return self.save_to(path, tga_bits); } let st = self.session.active().ok_or("no document")?; - // PDN imports default to our native format, which preserves Paint.NET's blend modes. + // PDN imports default to our native format, which preserves Paint.NET's blend modes; + // Krita imports to OpenRaster, which Krita opens with its layers. // Other files keep their format only when it can retain their layers. A plain raster // (including an unlocked transparent PNG) can keep its flat format; editable contents, // masks and layer appearance settings need a layered format even with just one layer. @@ -1273,7 +1274,11 @@ impl PhotocraftApp { Some(p) if writable => p.clone(), p => { let source = p.as_deref().unwrap_or(&st.doc.name); - let ext = if photocraft_engine::file_cmds::extension(source).as_deref() == Some("pdn") { "pcraft" } else { "psd" }; + let ext = match photocraft_engine::file_cmds::extension(source).as_deref() { + Some("pdn") => "pcraft", + Some("kra" | "krz") => "ora", + _ => "psd", + }; std::path::Path::new(source).with_extension(ext).to_string_lossy().into_owned() } }; diff --git a/docs/kra.md b/docs/kra.md new file mode 100644 index 0000000000..847a1f32d2 --- /dev/null +++ b/docs/kra.md @@ -0,0 +1,77 @@ +# Krita documents + +PhotoCraft opens Krita's native documents (`.kra`, and the `.krz` archive variant) with their +layers. File › Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation +(`doc.open`, MCP) all use the reader in `photocraft-io`; the browser build accepts them too. A +file is recognised by its `mimetype` entry (`application/x-krita`). Krita files are import-only: +Save suggests OpenRaster (`.ora`), which Krita opens with its layers, and `.pcraft` keeps +everything. + +## What is read + +| Krita | PhotoCraft | +|---|---| +| Paint layers, 8 and 16-bit RGBA, 8 and 16-bit gray | pixel layers, at the document's depth (a gray layer in an RGB document is converted) | +| Layer position (`x`, `y`), tiles anywhere, also off-canvas | kept | +| Groups, nested; pass-through groups; collapsed state | groups; pass-through; expanded state | +| Names, visibility, opacity, lock | kept | +| Blend modes | Krita's ids, mapped as for OpenRaster (`docs/ora.md`); a few are approximations, reported | +| Transparency masks | the layer mask (the first one; more are reported and left out) | +| Selection masks | ignored (a layer's local selection, not part of its look) | +| The image's ICC profile, resolution | kept | + +Left out with a warning per layer: vector, filter, fill, file and clone layers, filter, transform +and colorize masks, layers in another colour space than those above, Inherit Alpha, per-channel +blending flags (channel locks), and animation beyond the frame saved in the layer. Drawing +assistants and reference images are reported as left out. Krita's merged image still shows +the original look, so the result can differ where those were used. + +Documents whose image colour space we don't read yet (CMYK, Lab, XYZ, YCbCr, 16 and 32-bit +float) open as Krita's merged image (`mergedimage.png`), flagged as a preview, with a warning. + +## The format, as observed + +A ZIP whose first, stored entry is `mimetype`; `maindoc.xml` holds the node tree (topmost first) +and points each paint layer and mask at a file under `/layers/`. Those files are a +text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, `PIXELSIZE`, `DATA `) and per tile a +`x,y,LZF,` line followed by a compression flag and the tile's pixels in byte planes, LZF +compressed. RGB pixels are B, G, R, A; 16-bit samples are little-endian. A `.defaultpixel` file +next to each holds the colour outside its tiles. A group's own `x`/`y` does not move its +children (checked against Krita's merged image). + +## Limits and safety + +The reader uses the bounded ZIP reader of `photocraft-format` (no ZIP64, CRC-checked), parses +`maindoc.xml` with `roxmltree` (32 MiB cap; the DTD is declared by public id and not fetched), +and decodes tiles with a bounds-checked LZF decoder that must produce exactly one tile. Tiles +are at most 1024 px a side; canvases at most 300,000 px a side and 1 GiB of decoded pixels; +8,192 nodes; nesting at most `MAX_GROUP_DEPTH`. Version 1 tile files (Krita 1.x) are refused. +Malformed archives, XML, numbers, tile headers and tile data return errors; truncated and +corrupted files are covered by tests. + +## Verification + +Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and +check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels, +16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input. +`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves +it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a +session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`. + +Real-file checks use the Krita-authored oracles in +[photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`krita/`, made by +`tools/krita-oracles/generate.py`, which removes Krita's embedded third-party ICC profiles so +the files are sRGB): + +```sh +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored +``` + +2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the +others differ for known reasons (the three blend-mode tie-breaks as for OpenRaster, and the +file with a vector layer, which is left out). Before the profiles were stripped, a linear-light +16-bit document with its embedded profile matched Krita's merged image within 0.5/255 after +flattening and converting to sRGB. + +The reader is written from files Krita writes; no Krita code was used. diff --git a/docs/ora.md b/docs/ora.md index ac83b847b8..263befc00c 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -57,13 +57,12 @@ Real-file checks use Krita-authored oracles from KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture ``` -2026-10-09, Krita 5.2.9: 30 of 34 `.ora` files render within 3/255 of Krita's merged image; the -other four differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's -tie-break in Darker and Lighter Color, Hard Mix at a channel sum of exactly 1, and a -linear-light 16-bit document that Krita blends in linear light). In the other direction, -Krita opened every PhotoCraft re-export of those files and rendered 33 of them identically to -its original; the exception is Photoshop's Soft Light, which is written as `svg:soft-light`. -MyPaint and GIMP files have not been checked yet. +2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the +other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's +tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other +direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33 +of them identically to its original; the exception is Photoshop's Soft Light, which is written +as `svg:soft-light`. MyPaint and GIMP files have not been checked yet. Format reference: the [OpenRaster specification](https://www.openraster.org/). The reader and writer are written from the specification and from files Krita writes; no application code diff --git a/packaging/linux/ai.storyteller.photocraft.desktop b/packaging/linux/ai.storyteller.photocraft.desktop index a32b55cef5..bee0496f39 100644 --- a/packaging/linux/ai.storyteller.photocraft.desktop +++ b/packaging/linux/ai.storyteller.photocraft.desktop @@ -12,7 +12,7 @@ StartupNotify=false StartupWMClass=ai.storyteller.photocraft Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;image;editor;psd;layers;retouch;paint; -MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; +MimeType=application/x-photocraft;image/x-paintnet;image/openraster;application/x-krita;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; # AppRun registers the desktop entry and icons itself (#593); tell external # AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623). X-AppImage-Integrate=false diff --git a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in index 6fbba13511..932ff44e4c 100644 --- a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in @@ -64,6 +64,7 @@ application/x-photocraft image/x-paintnet image/openraster + application/x-krita image/vnd.adobe.photoshop image/x-psb diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 3a190a4b17..f573b316f4 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -112,6 +112,21 @@ image/openraster + + UTTypeIdentifier + ai.storyteller.photocraft.kra + UTTypeDescription + Krita Document + UTTypeConformsTo + public.datapublic.image + UTTypeTagSpecification + + public.filename-extension + krakrz + public.mime-type + application/x-krita + + UTTypeIdentifier ai.storyteller.photocraft.pdn @@ -212,6 +227,7 @@ ai.storyteller.photocraft.qoi ai.storyteller.photocraft.pdn ai.storyteller.photocraft.ora + ai.storyteller.photocraft.kra diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index 98dc1fdfd3..8be9ff893c 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -103,6 +103,8 @@ + + @@ -126,6 +128,8 @@ + + From 20539b5dc366c70f3ee8fd0502fa2f9b5f7ef17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Tue, 13 Oct 2026 04:50:20 +0200 Subject: [PATCH 124/223] i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436) * i18n(sr): add a Serbian (Latin, Ekavian) UI catalog Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures plus the union of the dynamic labels the other catalogs translate. The catalog is written in Latin script, Ekavian; every Serbian locale (sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it. Plurals use the one/few/many rule (plural_russian) with three forms per @plural entry. Original translations from the plain meaning of the English labels; no proprietary localisation resources were used (ATTRIBUTION.md and LICENSE-translations.txt updated). Tests cover locale resolution, contextual and id overrides, placeholders, and plurals across teens and compound counts. Co-Authored-By: Claude Opus 5.5 * Info.plist: declare the Serbian localization Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dejan Port Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + .../ui-egui/src/i18n/LICENSE-translations.txt | 9 +- crates/ui-egui/src/i18n/live_tests.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 66 +- crates/ui-egui/src/i18n/sr.tsv | 4169 +++++++++++++++++ crates/ui-egui/src/i18n/system.rs | 2 + docs/localization-parity.md | 7 +- docs/localization.md | 4 +- packaging/macos/Info.plist.in | 1 + xtask/src/i18n_coverage.rs | 1 + 10 files changed, 4254 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/i18n/sr.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b88e0901b..aa9f590ceb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,6 +23,7 @@ next to them. | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `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) | diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 0177bcb56d..c33e7a21e9 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and -Hungarian 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. +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. +The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. @@ -28,3 +28,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Hungarian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Serbian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 6dcec32e92..6ab8b340ad 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -20,6 +20,7 @@ fn requested_languages_resolve_regional_preferences() { ("fr-CA", "fr"), ("id-ID", "id"), ("vi-VN", "vi"), + ("sr-RS", "sr"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index bdf4ef5565..88ad43715f 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 20] = [ +pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -198,6 +198,10 @@ pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "ru", name: "Русский", source: catalog!("ru"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, // Ukrainian has the same one/few/many rule for integer counts. LangInfo { code: "uk", name: "Українська", source: catalog!("uk"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + // Serbian (Latin script, Ekavian) has the same one/few/many rule for integer counts. `sr-RS`, + // `sr-Latn-*` and `sr_RS@latin` resolve here; so do Cyrillic `sr` locales, which read Latin + // rather than English. + LangInfo { code: "sr", name: "Srpski", source: catalog!("sr"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -680,6 +684,66 @@ mod tests { } } + #[test] + fn serbian_resolves_locales_and_preferences() { + let sr = Lang::from_code("sr").expect("sr registered"); + assert_eq!(sr.name(), "Srpski"); + assert!(sr.complete_menus()); + for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { + assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); + assert_eq!(Lang::from_pref(tag), sr, "{tag}"); + } + assert_eq!(tr(sr, "File"), "Datoteka"); + assert_eq!(tr(sr, "Layer"), "Sloj"); + assert_eq!(tr(sr, "New document…"), "Novi dokument…"); + assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label"); + assert_eq!(tr(sr, "All"), "Sve"); + assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo"); + assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano"); + assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)"); + for key in ["Alt", "⌥"] { + assert_eq!( + fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), + format!("Dodaj masku (iz selekcije; {key} invertuje)") + ); + } + } + + #[test] + fn serbian_plural_messages_handle_teens_and_compound_counts() { + let sr = Lang::from_code("sr").expect("sr registered"); + for (n, item, layer) in [ + (0, "stavki", "slojeva"), + (1, "stavka", "sloj"), + (2, "stavke", "sloja"), + (4, "stavke", "sloja"), + (5, "stavki", "slojeva"), + (11, "stavki", "slojeva"), + (12, "stavki", "slojeva"), + (14, "stavki", "slojeva"), + (21, "stavka", "sloj"), + (22, "stavke", "sloja"), + (25, "stavki", "slojeva"), + (101, "stavka", "sloj"), + (111, "stavki", "slojeva"), + (112, "stavki", "slojeva"), + (122, "stavke", "sloja"), + ] { + assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}")); + assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); + assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}")); + } + for (n, expected) in [ + (1, "Uklonjena je 1 tačka uzorkovanja"), + (3, "Uklonjene su 3 tačke uzorkovanja"), + (5, "Uklonjeno je 5 tačaka uzorkovanja"), + (13, "Uklonjeno je 13 tačaka uzorkovanja"), + (21, "Uklonjena je 21 tačka uzorkovanja"), + ] { + assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected); + } + } + #[test] fn catalog_kinds_are_parsed_and_looked_up() { let c = Catalog::parse("# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n"); diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv new file mode 100644 index 0000000000..9d8aebef63 --- /dev/null +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -0,0 +1,4169 @@ +# Serbian (sr) catalog, Latin script, Ekavian. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists +# the Serbian forms "one|few|many" (1, 21, 101 … | 2–4, 22–24 … but +# not 12–14 | everything else), see `plural` in i18n/mod.rs +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Serbian computing vocabulary (sloj, maska, selekcija, četkica, gradijent, zamućenje, +# neprozirnost, providnost ...). No proprietary localisation resource, string table or product UI +# was consulted or copied; in particular not the Serbian localisation of Adobe Photoshop. Add +# entries the same way. +# Conventions: Serbian Latin script (latinica), Ekavian (ekavica), sentence case. Menus are nouns +# (Datoteka, Uređivanje, Slika ...), commands are imperatives (Otvori…, Sačuvaj kao…), tools read +# "Alatka …" (Alatka četkica, Alatka za pomeranje), presets are "šabloni", patterns "šare" and +# swatches "uzorci boja". Serbian quotation marks „…“ around names. Product and technology names +# (PhotoCraft, ArtCraft, Camera Raw, OpenType, Lab ...) stay as they are; RGB/CMYK stay as they +# are; the trailing "…" of dialog commands is kept. A count that is not an @plural entry is kept +# out of noun agreement ("Broj: {n}", "slojeva: {n}") so every number reads correctly. + +# Guide layout dialog + Rows Redovi + Number Broj + Margin Margina + Top: Gore: + Left: Levo: + Bottom: Dole: + Right: Desno: + Target: Cilj: + Selected Artboards Izabrane radne table + Center Columns Centriraj kolone + Clear Existing Guides Ukloni postojeće vodilje + Invalid guide layout Neispravan raspored vodilja + Leave empty for automatic size Ostavite prazno za automatsku veličinu + 5 Columns, 3 Rows 5 kolona, 3 reda + 8 Columns 8 kolona + 12 Columns 12 kolona + + File Datoteka + Edit Uređivanje + Image Slika + Layer Sloj + Type Tekst + Select Selekcija + Selected Izabrano + Scope Opseg + Top Left Gore levo + Top Right Gore desno + Bottom Left Dole levo + Bottom Right Dole desno + Filter Filter + View Prikaz + Window Prozor + Help Pomoć + (coming soon) (uskoro) + New… Novo… + Open… Otvori… + Open As… Otvori kao… + Open EXR Parts… Otvori EXR delove… + Add beauty precomp from the AOVs Dodaj „beauty“ predkompoziciju iz AOV prolaza + Both Oba + Left Levo + Right Desno + Open Recent Otvori nedavno + Clear Recent Files Obriši spisak nedavnih datoteka + Remove from Recent Ukloni iz nedavnih + Remove {name} from Recent Ukloni {name} iz nedavnih + Close Zatvori + Minimize Umanji + Maximize Uvećaj + Restore Vrati + Close All Zatvori sve + Close Others Zatvori ostale + Save Sačuvaj + Save As… Sačuvaj kao… + Save a Copy… Sačuvaj kopiju… + Revert Vrati sačuvano + Export Izvoz + Quick Export as PNG Brzi izvoz u PNG + Export As… Izvezi kao… + Export Preferences… Podešavanja izvoza… + Save for Web (Legacy)… Sačuvaj za veb (staro)… + Artboards to Files… Radne table u datoteke… + Artboards to PDF… Radne table u PDF… + Layers to Files… Slojevi u datoteke… + Layer Comps to Files… Kompozicije slojeva u datoteke… + Color Lookup Tables… Tabele preslikavanja boja… + Data Sets as Files… Skupovi podataka kao datoteke… + Paths to Illustrator… Putanje u Illustrator… + Render Video… Renderuj video… + Generate Generisanje + Image Assets Slikovni resursi + Place Embedded… Postavi ugrađeno… + Place Linked… Postavi povezano… + Package… Spakuj… + Automate Automatizacija + Batch… Grupna obrada… + Create Droplet… Napravi droplet… + Crop and Straighten Photos Opseci i ispravi fotografije + Contact Sheet II… Kontakt-list II… + Conditional Mode Change… Uslovna promena režima… + Fit Image… Uklopi sliku… + Lens Correction… Korekcija objektiva… + Merge to HDR Pro… Spoji u HDR Pro… + Photomerge… Spajanje fotografija… + Scripts Skripte + Image Processor… Obrada slika… + Delete All Empty Layers Obriši sve prazne slojeve + Flatten All Layer Effects Spljošti sve efekte slojeva + Flatten All Masks Spljošti sve maske + Script Events Manager… Upravljač događajima skripti… + Import Uvoz + Browse… Pregledaj… + Reveal in Finder Prikaži u Finderu + Show in Explorer Prikaži u Exploreru + Show in Folder Prikaži u fascikli + File Info… Informacije o datoteci… + Print… Štampaj… + Print One Copy Štampaj jedan primerak + Exit Izađi + Undo Opozovi + Redo Ponovi + Toggle Last State Prebaci na poslednje stanje + Fade… Ublaži… + Cut Iseci + Copy Kopiraj + Copy Merged Kopiraj spojeno + Paste Nalepi + Paste Special Specijalno lepljenje + Paste in Place Nalepi na isto mesto + Paste Into Nalepi unutra + Paste Outside Nalepi spolja + Paste Lorem Ipsum Nalepi Lorem Ipsum + Clear Obriši + Search… Pretraga… + Check Spelling… Proveri pravopis… + Find and Replace Text… Pronađi i zameni tekst… + Fill… Popuni… + Stroke… Ocrtaj… + Content-Aware Fill… Popuna prema sadržaju… + Delete and Fill Selection Obriši i popuni selekciju + Content-Aware Scale Skaliranje prema sadržaju + Puppet Warp Lutkarsko krivljenje + Perspective Warp Perspektivno krivljenje + Free Transform Slobodna transformacija + Transform Transformacija + Again Ponovo + Scale Skaliranje + Rotate Rotacija + Skew Iskošenje + Distort Izobličenje + Perspective Perspektiva + Warp Krivljenje + Flip Horizontal Okreni horizontalno + Flip Vertical Okreni vertikalno + Rotate 180° Rotiraj za 180° + Rotate 90° Clockwise Rotiraj za 90° udesno + Rotate 90° Counter Clockwise Rotiraj za 90° ulevo + 180° 180° + 90° Clockwise 90° udesno + 90° Counter Clockwise 90° ulevo + Arbitrary… Proizvoljno… + Auto-Align Layers… Automatski poravnaj slojeve… + Auto-Blend Layers… Automatski stopi slojeve… + Define Brush Preset… Definiši šablon četkice… + Define Pattern… Definiši šaru… + Define Custom Shape… Definiši prilagođeni oblik… + Purge Isprazni + Clipboard Ostava + Histories Istorije + Video Cache Video keš + All Sve + Presets Šabloni + Preset Manager… Upravljač šablonima… + Migrate Presets Prenesi šablone + Export/Import Presets… Izvoz/uvoz šablona… + Color Settings… Podešavanja boja… + Assign Profile… Dodeli profil… + Convert to Profile… Pretvori u profil… + Keyboard Shortcuts… Prečice na tastaturi… + Menus… Meniji… + Toolbar… Traka alatki… + Preferences Podešavanja + General… Opšte… + Interface… Interfejs… + Workspace… Radni prostor… + Tools… Alatke… + History Log… Dnevnik istorije… + File Handling… Rukovanje datotekama… + Export… Izvoz… + Performance… Performanse… + Scratch Disks… Radni diskovi… + Cursors… Kursori… + Transparency & Gamut… Providnost i gamut… + Units & Rulers… Jedinice i lenjiri… + Guides, Grid & Slices… Vodilje, mreža i isečci… + Plug-ins… Dodaci… + Type… Tekst… + Enhanced Controls… Napredne kontrole… + Camera Raw… Camera Raw… + Raw Defaults… Podrazumevana Raw podešavanja… + Integrations… Integracije… + AI Integrations… AI integracije… + Install Plug-in… Instaliraj dodatak… + Language Options Jezičke opcije + Settings… Podešavanja… + Mode Režim + Bitmap Bitmapa + Grayscale Nijanse sive + Duotone Duoton + Indexed Color Indeksirane boje + RGB Color RGB boje + CMYK Color CMYK boje + Lab Color Lab boje + Multichannel Višekanalno + 8 Bits/Channel 8 bita/kanal + 16 Bits/Channel 16 bita/kanal + 32 Bits/Channel 32 bita/kanal + Color Table… Tabela boja… + Bitmap… Bitmapa… + Duotone… Duoton… + Indexed Color… Indeksirane boje… + Adjustments Korekcije + Brightness/Contrast… Osvetljenost/kontrast… + Levels… Nivoi… + Curves… Krive… + Exposure… Ekspozicija… + Vibrance… Živost… + Hue/Saturation… Nijansa/zasićenost… + Color Balance… Balans boja… + Black & White… Crno-belo… + Photo Filter… Foto-filter… + Channel Mixer… Mešač kanala… + Color Lookup… Preslikavanje boja… + Invert Invertuj + Posterize… Posterizuj… + Threshold… Prag… + Gradient Map… Mapa gradijenta… + Selective Color… Selektivna boja… + Shadows/Highlights… Senke/svetla… + HDR Toning… HDR toniranje… + Desaturate Ukloni zasićenost + Match Color… Uskladi boje… + Replace Color… Zameni boju… + Equalize Izjednači + Auto Tone Automatski ton + Auto Contrast Automatski kontrast + Auto Color Automatske boje + Image Size… Veličina slike… + Canvas Size… Veličina platna… + Image Rotation Rotacija slike + Flip Canvas Horizontal Okreni platno horizontalno + Flip Canvas Vertical Okreni platno vertikalno + Crop Opseci + Trim… Odseci ivice… + Reveal All Otkrij sve + Duplicate… Dupliraj… + Apply Image… Primeni sliku… + Calculations… Proračuni… + Variables Promenljive + Define… Definiši… + Data Sets… Skupovi podataka… + Apply Data Set… Primeni skup podataka… + Variable Data Sets… Promenljivi skupovi podataka… + Trap… Trepovanje… + Analysis Analiza + Set Measurement Scale… Postavi razmeru merenja… + Select Data Points… Izaberi tačke podataka… + Record Measurements Zabeleži merenja + Ruler Tool Alatka lenjir + Count Tool Alatka za brojanje + Place Scale Marker… Postavi oznaku razmere… + Custom… Prilagođeno… + New Novo + Layer… Sloj… + Group… Grupa… + Group from Layers… Grupa od slojeva… + Artboard… Radna tabla… + Artboard from Group… Radna tabla od grupe… + Artboard from Layers… Radna tabla od slojeva… + Layer via Copy Sloj kopiranjem + Layer via Cut Sloj isecanjem + Layer From Background… Sloj od pozadine… + Layer from Background… Sloj od pozadine… + Background From Layer Pozadina od sloja + New Shape Layer Novi sloj oblika + New Type Layer Novi tekstualni sloj + New Fill Layer Novi sloj popune + New Adjustment Layer Novi korekcioni sloj + New Layer Based Slice Novi isečak zasnovan na sloju + New Smart Object via Copy Novi pametni objekat kopiranjem + New Blank Video Layer Novi prazan video sloj + New Video Layer from File… Novi video sloj iz datoteke… + Duplicate Layer… Dupliraj sloj… + Delete Layer Obriši sloj + Delete Obriši + Rename Layer Preimenuj sloj + Create Layer Napravi sloj + Layer Style Stil sloja + Blending Options… Opcije stapanja… + Bevel & Emboss… Kosina i reljef… + Inner Shadow… Unutrašnja senka… + Inner Glow… Unutrašnji sjaj… + Satin… Saten… + Color Overlay… Prekrivanje bojom… + Gradient Overlay… Prekrivanje gradijentom… + Pattern Overlay… Prekrivanje šarom… + Outer Glow… Spoljašnji sjaj… + Drop Shadow… Bačena senka… + Copy Layer Style Kopiraj stil sloja + Paste Layer Style Nalepi stil sloja + Clear Layer Style Ukloni stil sloja + Global Light… Globalno svetlo… + Hide All Effects Sakrij sve efekte + Scale Effects… Skaliraj efekte… + Smart Filter Pametni filter + Convert for Smart Filters Pretvori za pametne filtere + Clear Smart Filters Ukloni pametne filtere + Disable Smart Filters Onemogući pametne filtere + Delete Filter Mask Obriši masku filtera + Disable Filter Mask Onemogući masku filtera + Solid Color… Puna boja… + Gradient… Gradijent… + Pattern… Šara… + Layer Mask Maska sloja + Vector Mask Vektorska maska + Reveal Selection Otkrij selekciju + Hide Selection Sakrij selekciju + Hide All Sakrij sve + From Transparency Iz providnosti + Delete Vector Mask Obriši vektorsku masku + Enable Layer Mask Omogući masku sloja + Disable Layer Mask Onemogući masku sloja + Enable Vector Mask Omogući vektorsku masku + Link Layer Mask Poveži masku sloja + Unlink Layer Mask Razveži masku sloja + Link Vector Mask Poveži vektorsku masku + Add Vector Mask Dodaj vektorsku masku + Vector Mask from Current Path Vektorska maska od tekuće putanje + Create Clipping Mask Napravi masku isecanja + Release Clipping Mask Otpusti masku isecanja + Smart Objects Pametni objekti + Smart Object Pametni objekat + Convert to Smart Object Pretvori u pametni objekat + Convert to Embedded Pretvori u ugrađeni + Convert to Layers Pretvori u slojeve + Convert to Linked… Pretvori u povezani… + Edit Contents Uredi sadržaj + Replace Contents… Zameni sadržaj… + Relink to File… Ponovo poveži sa datotekom… + Export Contents… Izvezi sadržaj… + Update Modified Content Ažuriraj izmenjeni sadržaj + Update All Modified Content Ažuriraj sav izmenjeni sadržaj + Stack Mode Režim steka + Entropy Entropija + Kurtosis Kurtozis + Maximum Maksimum + Mean Srednja vrednost + Median Medijana + Minimum Minimum + Range Opseg + Skewness Asimetrija + Standard Deviation Standardna devijacija + Summation Zbir + Variance Varijansa + Video Layers Video slojevi + Interpret Footage… Tumači snimak… + Replace Footage… Zameni snimak… + Reload Frame Ponovo učitaj kadar + Show Altered Video Prikaži izmenjeni video + Restore Frame Vrati kadar + Restore All Frames Vrati sve kadrove + Insert Blank Frame Umetni prazan kadar + Delete Frame Obriši kadar + Duplicate Frame Dupliraj kadar + Frame from Layers Okvir od slojeva + Video Frames to Layers… Video kadrovi u slojeve… + Rasterize Rasterizuj + Rasterize Type Rasterizuj tekst + Rasterize Type Layer Rasterizuj tekstualni sloj + Rasterize Shape Rasterizuj oblik + New Guide Layout… Novi raspored vodilja… + New Guides From Shape Nove vodilje od oblika + Lock Layers… Zaključaj slojeve… + Link Layers Poveži slojeve + Select Linked Layers Izaberi povezane slojeve + Merge Layers Spoji slojeve + Merge Down Spoji nadole + Merge Group Spoji grupu + Merge Visible Spoji vidljive + Stamp Visible Utisni vidljive + Stamp Down Utisni nadole + Flatten Image Spljošti sliku + Matting Čišćenje ivica + Color Decontaminate… Dekontaminacija boja… + Remove Black Matte Ukloni crni oreol + Remove White Matte Ukloni beli oreol + Defringe… Ukloni rub… + Group Layers Grupiši slojeve + Ungroup Layers Razgrupiši slojeve + Hide Layers Sakrij slojeve + Arrange Raspored + Bring to Front Premesti na vrh + Bring Forward Pomeri napred + Send Backward Pomeri nazad + Send to Back Premesti na dno + Reverse Obrni + Combine Shapes Kombinuj oblike + Unite Shapes Ujedini oblike + Subtract Front Shape Oduzmi prednji oblik + Intersect Shape Areas Presek oblasti oblika + Exclude Overlapping Shapes Isključi preklapajuće oblike + Merge Shape Components Spoji komponente oblika + Align Poravnaj + Top Edges Gornje ivice + Vertical Centers Vertikalni centri + Bottom Edges Donje ivice + Left Edges Leve ivice + Horizontal Centers Horizontalni centri + Right Edges Desne ivice + Distribute Raspodeli + Vertically Vertikalno + Horizontally Horizontalno + Layer Edges Ivice sloja + Isolate Layers Izoluj slojeve + Layer Content Options… Opcije sadržaja sloja… + Fill Content Sadržaj popune + Deselect Layers Poništi izbor slojeva + Find Layers Pronađi slojeve + Layer Comps Kompozicije slojeva + Layers Slojevi + All Layers Svi slojevi + Hidden Layers Skriveni slojevi + Mask All Objects Maskiraj sve objekte + Subject Subjekat + Sky Nebo + Panels Paneli + Character Panel Panel znakova + Paragraph Panel Panel pasusa + Character Styles Panel Panel stilova znakova + Paragraph Styles Panel Panel stilova pasusa + Glyphs Panel Panel glifova + Anti-Alias Uglađivanje ivica + Sharp Oštro + Crisp Jasno + Strong Snažno + Smooth Glatko + Windows LCD Windows LCD + Windows Windows + Orientation Orijentacija + Horizontal Horizontalno + Vertical Vertikalno + OpenType OpenType + Standard Ligatures Standardne ligature + Contextual Alternates Kontekstualne alternative + Discretionary Ligatures Opcione ligature + Swash Kićeni oblici + Oldstyle Starinske cifre + Stylistic Alternates Stilske alternative + Titling Alternates Naslovne alternative + Ornaments Ornamenti + Ordinals Redni brojevi + Fractions Razlomci + Create Work Path Napravi radnu putanju + Convert to Shape Pretvori u oblik + Warp Text… Krivljenje teksta… + Convert to Point Text Pretvori u tačkasti tekst + Convert to Paragraph Text Pretvori u tekst pasusa + Default Features Podrazumevane funkcije + East Asian Features Funkcije za istočnoazijska pisma + Middle Eastern Features Funkcije za bliskoistočna pisma + Middle Eastern & South Asian Composer Slagač za bliskoistočna i južnoazijska pisma + Update All Text Layers Ažuriraj sve tekstualne slojeve + Replace All Missing Fonts Zameni sve fontove koji nedostaju + Resolve Missing Fonts… Razreši fontove koji nedostaju… + Font Preview Size Veličina pregleda fonta + None Ništa + Small Mala + Medium Srednja + Large Velika + Extra Large Veoma velika + Huge Ogromna + Load Default Type Styles Učitaj podrazumevane stilove teksta + Save Default Type Styles Sačuvaj podrazumevane stilove teksta + Deselect Poništi selekciju + Reselect Ponovo izaberi + Inverse Inverzno + Inverse Selection Obrni selekciju + Similar Slično + Color Range… Opseg boja… + Focus Area… Oblast fokusa… + Select and Mask… Izaberi i maskiraj… +# Select and Mask dialog labels + Smart Radius Pametni poluprečnik + Shift Edge Pomeri ivicu + Decontaminate Očisti boje + Output Izlaz + Refine Edge Doteraj ivicu + Modify Izmeni + Border… Obod… + Smooth… Izgladi… + Expand… Proširi… + Contract… Suzi… + Feather… Omekšaj ivice… + Grow Uvećaj + Transform Selection Transformiši selekciju + Edit in Quick Mask Mode Uredi u režimu brze maske + Load Selection… Učitaj selekciju… + Save Selection… Sačuvaj selekciju… + Match All Uskladi sve + Last Filter Poslednji filter + Filter Gallery… Galerija filtera… + Adaptive Wide Angle… Prilagodljivi široki ugao… + Camera Raw Filter… Camera Raw filter… + Liquify… Tečno izobličavanje… + Vanishing Point… Tačka nestajanja… + Blur Zamućenje + Blur Gallery Galerija zamućenja + Field Blur… Zamućenje polja… + Iris Blur… Iris zamućenje… + Tilt-Shift… Tilt-shift… + Path Blur… Zamućenje duž putanje… + Spin Blur… Rotaciono zamućenje… + Blur More Jače zamućenje + Box Blur… Pravougaono zamućenje… + Gaussian Blur… Gausovo zamućenje… + Lens Blur… Zamućenje objektiva… + Motion Blur… Zamućenje pokreta… + Radial Blur… Radijalno zamućenje… + Shape Blur… Zamućenje oblikom… + Smart Blur… Pametno zamućenje… + Surface Blur… Površinsko zamućenje… + Average Prosek + Displace… Premeštanje… + Pinch… Skupljanje… + Polar Coordinates… Polarne koordinate… + Ripple… Talasanje… + Shear… Smicanje… + Spherize… Sferizacija… + Twirl… Uvrtanje… + Wave… Talas… + ZigZag… Cik-cak… + Noise Šum + Add Noise… Dodaj šum… + Despeckle Ukloni mrlje + Dust & Scratches… Prašina i ogrebotine… + Median… Medijana… + Reduce Noise… Smanji šum… + Pixelate Pikselizacija + Color Halftone… Polutonski raster u boji… + Crystallize… Kristalizacija… + Facet Fasete + Fragment Fragmentacija + Mezzotint… Mecotinta… + Mosaic… Mozaik… + Pointillize… Poentilizam… + Render Renderovanje + Clouds Oblaci + Difference Clouds Oblaci razlike + Fibers… Vlakna… + Flame… Plamen… + Lens Flare… Odsjaj objektiva… + Lighting Effects… Efekti osvetljenja… + Relight… Ponovno osvetljavanje… + Picture Frame… Ram za sliku… + Tree… Drvo… + Sharpen Izoštravanje + Sharpen Edges Izoštri ivice + Sharpen More Jače izoštravanje + Smart Sharpen… Pametno izoštravanje… + Unsharp Mask… Maska neoštrine… + High Pass… Visokopropusni filter… + Stylize Stilizacija + Diffuse… Rasipanje… + Emboss… Reljef… + Extrude… Istiskivanje… + Find Edges Pronađi ivice + Oil Paint… Uljana boja… + Solarize Solarizacija + Tiles… Pločice… + Trace Contour… Praćenje konture… + Wind… Vetar… + Video Video + De-Interlace… Uklanjanje preplitanja… + NTSC Colors NTSC boje + Other Ostalo + Maximum… Maksimum… + Minimum… Minimum… + Offset… Pomak… + Plug-ins Dodaci + Proof Setup Podešavanje probnog prikaza + Proof Colors Probne boje + Gamut Warning Upozorenje o gamutu + Working CMYK Radni CMYK + Working Cyan Plate Radna cijan ploča + Working Magenta Plate Radna magenta ploča + Working Yellow Plate Radna žuta ploča + Working Black Plate Radna crna ploča + Working CMY Plate Radna CMY ploča + Legacy Macintosh RGB Stari Macintosh RGB + Internet Standard RGB Internet standard RGB + Monitor RGB RGB monitora + Color Blindness — Protanopia-type Daltonizam — tip protanopije + Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije + Zoom In Uvećaj prikaz + Zoom Out Umanji prikaz + Fit on Screen Uklopi u ekran + Fit Layer(s) on Screen Uklopi sloj(eve) u ekran + Fit Artboard on Screen Uklopi radnu tablu u ekran + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Veličina za štampu + Pixel Aspect Ratio Odnos stranica piksela + Pixel Aspect Ratio Correction Korekcija odnosa stranica piksela + Custom Pixel Aspect Ratio… Prilagođeni odnos stranica piksela… + Square Kvadrat + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC, široki ekran (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL, široki ekran (1.46) + Anamorphic 2:1 (2) Anamorfno 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… Opcije 32-bitnog pregleda… + Screen Mode Režim ekrana + Standard Screen Mode Standardni režim ekrana + Full Screen Mode With Menu Bar Režim celog ekrana sa trakom menija + Full Screen Mode Režim celog ekrana + Cycle Screen Mode Smenjuj režime ekrana + Show Prikaži + Extras Dodatni elementi + Selection Edges Ivice selekcije + Target Path Ciljna putanja + Current Path Tekuća putanja + Grid Mreža + Guides Vodilje + Canvas Guides Vodilje platna + Artboard Guides Vodilje radne table + Count Broj + Smart Guides Pametne vodilje + Slices Isečci + Notes Beleške + Pixel Grid Mreža piksela + Pixel Art Piksel-art + Pixel Art Preview Pregled piksel-arta + Pattern Preview Pregled šare + Brush Preview Pregled četkice + Mesh Mrežica + Edit Pins Uredi čiode + Document Bounds Granice dokumenta + Show Extras Options… Opcije prikaza dodatnih elemenata… + Rulers Lenjiri + Snap Prianjanje + Snap To Prianjaj uz + Lock Guides Zaključaj vodilje + Clear Guides Ukloni vodilje + Clear Selected Artboard Guides Ukloni vodilje izabranih radnih tabli + Clear Canvas Guides Ukloni vodilje platna + New Guide… Nova vodilja… + Lock Slices Zaključaj isečke + Clear Slices Ukloni isečke + Match Zoom Uskladi zum + Match Location Uskladi položaj + Match Rotation Uskladi rotaciju + Cascade Kaskadno + Tile Popločaj + Tile All Horizontally Popločaj sve horizontalno + Tile All Vertically Popločaj sve vertikalno + 2-up Horizontal Po 2, horizontalno + 2-up Vertical Po 2, vertikalno + 3-up Horizontal Po 3, horizontalno + 3-up Stacked Po 3, naslagano + 3-up Vertical Po 3, vertikalno + 4-up Po 4 + 6-up Po 6 + Consolidate All to Tabs Objedini sve u kartice + Float in Window Odvoji u prozor + Float All in Windows Odvoji sve u prozore + New Window for Document Novi prozor za dokument + Workspace Radni prostor + Essentials Osnovno + Graphic and Web Grafika i veb + Painting Slikanje + Photography Fotografija + New Workspace… Novi radni prostor… + Delete Workspace… Obriši radni prostor… + Reset Workspace Resetuj radni prostor + Reset Tool Resetuj alatku + Reset All Tools Resetuj sve alatke + Reset all tools to their default settings? Vratiti sve alatke na podrazumevana podešavanja? + Lock Workspace Zaključaj radni prostor + Theme Tema + Pro Theme Pro tema + Pro Medium Gray Theme Pro srednje siva tema + Pro Light Gray Theme Pro svetlosiva tema + Pro Light Theme Pro svetla tema + Studio Theme Studio tema + Studio Light Theme Studio svetla tema + Adwaita Light Theme Adwaita svetla tema + Adwaita Dark Theme Adwaita tamna tema + Classic Theme Klasična tema + Solarized dark Solarized tamna + Solarized Dark Theme Solarized tamna tema + Breeze dark Breeze tamna + Breeze Dark Theme Breeze tamna tema + Breeze light Breeze svetla + Breeze Light Theme Breeze svetla tema + Next Theme Sledeća tema + Actions Radnje + Brush Settings Podešavanja četkice + Brushes Četkice + Channels Kanali + Character Znak + Character Styles Stilovi znakova + Clone Source Izvor kloniranja + Color Boja + Glyphs Glifovi + Gradients Gradijenti + Histogram Histogram + History Istorija + Info Informacije + Measurement Log Dnevnik merenja + Modifier Keys Modifikatorski tasteri + Navigator Navigator + Options Opcije + Paragraph Pasus + Paragraph Styles Stilovi pasusa + Paths Putanje + Patterns Šare + Properties Svojstva + Shapes Oblici + Styles Stilovi + Swatches Uzorci boja + Timeline Vremenska linija + Tool Presets Šabloni alatki + Tools Alatke + About PhotoCraft O programu PhotoCraft + PhotoCraft Website Veb-sajt PhotoCraft-a + PhotoCraft on GitHub PhotoCraft na GitHub-u + ArtCraft Website Veb-sajt ArtCraft-a + Join the ArtCraft Discord… Pridružite se ArtCraft Discord zajednici… + Report an Issue… Prijavi problem… + WIA Support… WIA podrška… + Apply Primeni + HSB/HSL HSB/HSL + Invert… Invertuj… + Load Files into Stack… Učitaj datoteke u stek… + Motion Pokret + Notes… Beleške… + Other… Ostalo… + Remove Warp Split Ukloni podelu krivljenja + Shape Oblik + Split Warp Crosswise Podeli krivljenje unakrsno + Split Warp Horizontally Podeli krivljenje horizontalno + Split Warp Vertically Podeli krivljenje vertikalno + Statistics… Statistika… + Refine Strokes Doteraj poteze +@plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki + Auto Automatski + (print to the printer) (štampaj na štampaču) + + Add variable + Dodaj promenljivu + 30 frames @ 30 fps 30 kadrova pri 30 fps + 3D LUT File 3D LUT datoteka + Absolute Apsolutno + Absolute Colorimetric Apsolutno kolorimetrijski + Action Radnja + Adaptive Prilagodljivo + Adaptive Wide Angle isn't open Prilagodljivi široki ugao nije otvoren + Add Dodaj + Add Layer Mask Dodaj masku sloja + Add Stop Dodaj tačku + Add a layer style Dodaj stil sloja + Add a mask (from the selection; {key} inverts) Dodaj masku (iz selekcije; {key} invertuje) + Add an adjustment Dodaj korekciju + Add disk Dodaj disk + Add space after paragraph Dodaj razmak posle pasusa + Add space before paragraph Dodaj razmak pre pasusa + Add to Sample Dodaj u uzorak + Add to selection ({key}) Dodaj u selekciju ({key}) + Adjust for Zoom Prilagodi zumu + Adjustment visible Korekcija je vidljiva + Adobe RGB (1998) compatible Kompatibilno sa Adobe RGB (1998) + Align and Distribute Poravnanje i raspodela + Align and distribute Poravnanje i raspodela + Align horizontal centers Poravnaj horizontalne centre + Align left edges Poravnaj leve ivice + Align right edges Poravnaj desne ivice + Aligned Poravnato + All Caps Sve velika slova + All Slices Svi isečci + All User Slices Svi korisnički isečci + All shortcuts reset to PhotoCraft defaults. Sve prečice su vraćene na podrazumevane vrednosti PhotoCraft-a. + Alt Alt + Always use pressure for opacity Uvek koristi pritisak za neprozirnost + Always use pressure for size Uvek koristi pritisak za veličinu + Amount Količina + Anchor: Sidro: + Angle Ugao + Angle Gradient Ugaoni gradijent + Angle Jitter Kolebanje ugla + Answer the unsaved-changes prompt first Prvo odgovorite na upit o nesačuvanim izmenama + Anti-alias Uglađivanje ivica + Appearance Izgled + Appearance (Layer Style) Izgled (stil sloja) + Apply Layer Mask Primeni masku sloja + Apply Per Tip Primeni po vrhu + Apply next selected layer comp Primeni sledeću izabranu kompoziciju slojeva + Apply previous selected layer comp Primeni prethodnu izabranu kompoziciju slojeva + Artboard Radna tabla + Artboard Background Color Boja pozadine radne table + Assign working space Dodeli radni prostor boja + Author: Autor: + Auto Hide Automatski sakrij + Auto-Select: Automatski izbor: + Automatically level near-horizontal line segments Automatski izravnaj skoro horizontalne segmente linija + Automatically straighten and level Automatski ispravi i izravnaj + Automatically straighten near-vertical line segments Automatski ispravi skoro vertikalne segmente linija + Background Pozadina + Background Color Boja pozadine + Background Contents Sadržaj pozadine + Balance Balans + Baseline Osnovna linija + Baseline shift Pomak osnovne linije + Basic Osnovno + Before (Y) Pre (Y) + Begin recording Započni snimanje + Bend: Savijanje: + Bicubic Bikubično + Bicubic (smooth gradients) Bikubično (glatki prelazi) + Automatic Automatski + Bicubic Smoother (enlargement) Bikubično glađe (uvećanje) + Bicubic Sharper (reduction) Bikubično oštrije (smanjenje) + Bilinear Bilinearno + Black Crna + Black & White Crno-belo + Black Point Compensation Kompenzacija crne tačke + Black, White Crna, bela + Blacks Crne + Blending Stapanje + Blending Options Opcije stapanja + Blue Plava + Blue, Red, Yellow Plava, crvena, žuta + Both Axes Obe ose + Bottom Dole + Brightness Osvetljenost + Brightness Jitter Kolebanje osvetljenosti + Brightness/Contrast Osvetljenost/kontrast + Bring the selected pin forward Pomeri izabranu čiodu napred + Brush Preset picker Birač šablona četkice + Brush Reconstruct Options Opcije četkice za rekonstrukciju + Brush Tool Options Opcije alatke četkica + Cache Level: Nivo keša: + Camera Raw isn't open Camera Raw nije otvoren + Cancel Otkaži + Cancel (Esc) Otkaži (Esc) + Cancel any current edits (Esc) Otkaži sve tekuće izmene (Esc) + Cancel current crop operation (Esc) Otkaži tekuće opsecanje (Esc) + Cancel transform (Esc) Otkaži transformaciju (Esc) + Cancel warp (Esc) Otkaži krivljenje (Esc) + Canvas Platno + Canvas extension color: Boja proširenja platna: + Capture Zabeleži + Capture the visibility, position and style of every layer with + below, then switch between versions. Zabeležite vidljivost, položaj i stil svakog sloja dugmetom + ispod, a zatim se prebacujte između verzija. + Catch-up on Stroke End Sustizanje na kraju poteza + Center Centar + Center Crop Marks Središnje oznake za sečenje + Center text Centriraj tekst + Change Promeni + Change All Promeni sve + Change Screen Mode (F) Promeni režim ekrana (F) + Change To: Promeni u: + Channel Mixer Mešač kanala + Channel: Kanal: + Check All Layers Proveri sve slojeve + Check Spelling Provera pravopisa + Clarity Jasnoća + Clear All Obriši sve + Clear Override Ukloni nadjačavanje + Clear Samples Obriši uzorke + Click sets foreground · right-click sets background Klik postavlja boju prednjeg plana · desni klik postavlja boju pozadine + Click the first point or press {key} to close · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · Esc otkazuje + Click to add a point · drag off or {key}-click or Delete to remove Kliknite da dodate tačku · prevucite van, {key}-klik ili Delete da uklonite + Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknite da primenite, {key}-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to edit the gradient Kliknite da uredite gradijent + Click to restore the current colour Kliknite da vratite tekuću boju + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknite da uvećate · {key}-klik da umanjite · prevucite udesno/ulevo da uvećate/umanjite + Click with the Note tool to add a note. Kliknite alatkom za beleške da dodate belešku. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · {key} završi · Esc otkaži + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · {alt}-prevlačenje: razdvoji ručicu · Klik ili prevlačenje prve tačke: zatvori · {key} završi · Esc otkaži + Clip to layer below Iseci prema sloju ispod + Clipped Isečeno + Close Style Options Zatvori opcije stila + Cmd Cmd + Coated CMYK Premazani CMYK + Color & Swatches Boja i uzorci + Color Balance Balans boja + Color Detail Detalji boje + Color Grading Gradacija boja + Color Lookup Preslikavanje boja + Color Management Upravljanje bojama + Color Mixer Mešač boja + Color Mode Režim boja + Color Priority Prioritet boje + Color Range preview Pregled opsega boja + Color: Boja: + Colorize Oboji + Colors Boje + Commit Perspective Warp ({key}) Potvrdi perspektivno krivljenje ({key}) + Commit Puppet Warp ({key}) Potvrdi lutkarsko krivljenje ({key}) + Commit any current edits ({key}) Potvrdi sve tekuće izmene ({key}) + Commit current crop operation ({key}) Potvrdi tekuće opsecanje ({key}) + Commit transform ({key}) Potvrdi transformaciju ({key}) + Commit warp ({key}) Potvrdi krivljenje ({key}) + Constant Konstantno + Constrain proportions Zadrži proporcije + Contact Sheet II Kontakt-list II + Content-Aware Prema sadržaju + 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 + Copyright Autorska prava + Copyright and Contact Info Autorska prava i kontakt podaci + Corner Crop Marks Ugaone oznake za sečenje + Corner radius Poluprečnik ugla + Correction Korekcija + Count Jitter Kolebanje broja + Create Napravi + Create Droplet Napravi droplet + Create Texture Napravi teksturu + Create Video Timeline Napravi video vremensku liniju + Create a new count group Napravi novu grupu brojanja + Create a new document or open an existing file. Napravite novi dokument ili otvorite postojeću datoteku. + Create a new group Napravi novu grupu + Create a new layer Napravi novi sloj + Create new action Napravi novu radnju + Create new channel Napravi novi kanal + Create new document from current state Napravi novi dokument od tekućeg stanja + Create new fill or adjustment layer Napravi novi sloj popune ili korekcioni sloj + Create new gradient Napravi novi gradijent + Create new group Napravi novu grupu + Create new layer comp Napravi novu kompoziciju slojeva + Create new pattern from the selection Napravi novu šaru od selekcije + Create new shape from the current path Napravi novi oblik od tekuće putanje + Create new snapshot Napravi novi snimak stanja + Create new tool preset Napravi novi šablon alatke + Crop Factor Faktor izreza + Ctrl Ctrl + Current & Below Tekući i ispod + Current Layer Tekući sloj + Current Size Trenutna veličina + Current Tool Only Samo tekuća alatka + Curve Kriva + Curves Krive + Custom Prilagođeno + Custom Shapes Prilagođeni oblici + Cyan Cijan + Cyan · Red Cijan · crvena + Darken Zatamnjenje + Darks Tamni tonovi + Data Sets Skupovi podataka + Default Podrazumevano + Default colours (D) Podrazumevane boje (D) + Default printer Podrazumevani štampač + Default restored. Podrazumevano stanje je vraćeno. + Define Definiši + Define at least one variable first. Prvo definišite bar jednu promenljivu. + Dehaze Uklanjanje izmaglice + Delete Cropped Pixels Obriši isečene piksele + Delete Group Obriši grupu + Delete Layer Mask Obriši masku sloja + Delete Layers Obriši slojeve + Delete Shortcut Obriši prečicu + Delete Timeline Obriši vremensku liniju + Delete Tool Preset Obriši šablon alatke + Delete count group Obriši grupu brojanja + Delete current channel Obriši tekući kanal + Delete current state Obriši tekuće stanje + Delete effect layer Obriši sloj efekta + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Obrisati sloj „{name}“? + Delete {count} layers? {names} Obrisati izabrane slojeve ({count})? {names} + Delete layer? Obrisati sloj? + The document is closed Dokument je zatvoren + The document or selected layers changed. Please request deletion again. Dokument ili izabrani slojevi su se promenili. Ponovo zatražite brisanje. + Missing delete command Nedostaje komanda za brisanje + Invalid delete command Neispravna komanda za brisanje + Layer deleted, but confirmation preference could not be saved: {error} Sloj je obrisan, ali podešavanje potvrde nije moglo da se sačuva: {error} + + Delete layer Obriši sloj + Delete layer comp Obriši kompoziciju slojeva + Delete note Obriši belešku + Delete selected measurements Obriši izabrana merenja + Delete style Obriši stil + Delete tool preset Obriši šablon alatke + Density Gustina + Density: Gustina: + Depth Dubina + Depth Jitter Kolebanje dubine + Description Opis + Deselect All Poništi sav izbor + Detail Detalji + Diamond Gradient Romboidni gradijent + Difference Razlika + Diffusion Difuzija + Discard the embedded profile (don't color manage) Odbaci ugrađeni profil (bez upravljanja bojama) + Discontiguous Nesusedno + Discord Discord + Dismiss Odbaci + Display P3 Display P3 + Display Text Tekst za prikaz + Dither Diterovanje + Divide… Podeli… + Document Dokument + Don't Save Nemoj sačuvati + No Ne + Yes Da + Done Gotovo + Dots Tačke + Drag a rectangle around the object Prevucite pravougaonik oko objekta + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik za brisanje. + Drag to move the active shape's path or the Work Path Prevucite da pomerite putanju aktivnog oblika ili radnu putanju + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: izaberi sidrišnu tačku · Prevlačenje: pomeri tačku, ručicu ili segment · {shift}-klik: dodaj · {alt}-klik: cela podputanja + Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatkom pero (P) ili napravite radnu putanju od selekcije. + Drop an image or PSD anywhere to open it. Prevucite sliku ili PSD bilo gde da biste je otvorili. + Duplicate Layers… Dupliraj slojeve… + Duplicate style Dupliraj stil + Edit Toolbar… Uredi traku alatki… + Edit in Quick Mask Mode (Q) Uredi u režimu brze maske (Q) + Edit in Standard Mode (Q) Uredi u standardnom režimu (Q) + Effects Efekti + Embed Color Profile Ugradi profil boja + Embedded Profile Mismatch Neslaganje ugrađenog profila + Enable airbrush-style build-up effects Omogući postepeno nagomilavanje kao kod vazdušne četkice + Enhance Edge Poboljšaj ivicu + Entire Font Ceo font + Exact Tačno + Expansion: Proširenje: + Export Presets Izvezi šablone + Export selected measurements (CSV) Izvezi izabrana merenja (CSV) + Exposure Ekspozicija + Exposure and Gamma Ekspozicija i gama + Exposure: Ekspozicija: + Eyedropper Pipeta + Factor: Faktor: + Faux Bold Lažno podebljano + Faux Italic Lažni kurziv + Feather Omekšavanje ivica + Fewer Points Manje tačaka + File Settings Podešavanja datoteke + Fill Popuna + Fill: Popuna: + Filter for adjustment layers Filtriraj korekcione slojeve + Filter for pixel layers Filtriraj piksel slojeve + Filter for shape layers Filtriraj slojeve oblika + Filter for smart objects Filtriraj pametne objekte + Filter for type layers Filtriraj tekstualne slojeve + Filter: Filter: + Finger Painting Slikanje prstom + Fisheye Riblje oko + Fit Uklopi + Fit Screen Uklopi u ekran + Fill Screen Popuni ekran + Fit in View Uklopi u prikaz + Fixed Fiksno + Fixed Ratio Fiksni odnos + Fixed Size Fiksna veličina + Flip Okreni + Flip X Okreni po X + Flip X Jitter Kolebanje okretanja po X + Flip Y Okreni po Y + Flip Y Jitter Kolebanje okretanja po Y + Flow Protok + Flow Jitter Kolebanje protoka + Focal Length Žižna daljina + Font Font + Font Size: Veličina fonta: + Font size Veličina fonta + Foreground Prednji plan + Foreground to Background Od prednjeg plana do pozadine + Foreground/Background Jitter Kolebanje prednjeg plana/pozadine + Format Format + Frame from Layers… Okvir od slojeva… + Free Slobodno + Full Spherical Potpuno sferno + Fuzziness: Rasplinutost: + GPU GPU + Gamma Correction Korekcija game + Gamma: Gama: + Global Globalno + Glow Color Boja sjaja + Gradient Map Mapa gradijenta + Grain Zrno + Gray Siva + Gray Gamma 2.2 Siva gama 2.2 + Green Zelena + Group Grupa + Hardness Tvrdoća + Heart Srce + Height Visina + Height: Visina: + Hide Auto Slices Sakrij automatske isečke + Hide Properties Sakrij svojstva + Highlight Compression Kompresija svetlih tonova + Highlight Priority Prioritet svetlih tonova + Highlights Svetla + Home Početna + Horizontal scale Horizontalna razmera + Hue Nijansa + Hue Jitter Kolebanje nijanse + Hue/Saturation Nijansa/zasićenost + Hyphenate Deljenje reči na slogove + Ignore Zanemari + Ignore All Zanemari sve + Image Size Veličina slike + Import Brushes… Uvezi četkice… + Import brushes (.abr) Uvezi četkice (.abr) + Import Presets Uvezi šablone + Imported Uvezeno + Indent First Uvlačenje prvog reda + Indent Left Levo uvlačenje + Indent Right Desno uvlačenje + Indent first line Uvuci prvi red + Indent left margin Uvlačenje leve margine + Indent right margin Uvlačenje desne margine + Input: Ulaz: + Inside Unutra + Interlaced Isprepletano + Interpolation: Interpolacija: + Intersect with selection ({key}) Presek sa selekcijom ({key}) + Invert All Invertuj sve + Invert has no settings. Invertovanje nema podešavanja. + Italic Kurziv + Join us on Discord Pridružite nam se na Discordu + Justify all Obostrano poravnaj sve redove + Justify last centered Obostrano, poslednji red centriran + Justify last left Obostrano, poslednji red levo + Justify last right Obostrano, poslednji red desno + Kerning Kerning + Keyboard Shortcuts Prečice na tastaturi + Keyboard Shortcuts and Menus Prečice na tastaturi i meniji + Kind Vrsta + Label Size Veličina oznake + Labels Oznake + Lanczos (sharp) Lanczos (oštro) + Landscape Položeno + Last Document State Poslednje stanje dokumenta + Layout Raspored + Leading Prored + Leading (set to the font size × auto-leading when Auto) Prored (veličina fonta × automatski prored kada je Automatski) + Leave as is (don't color manage) Ostavi kako jeste (bez upravljanja bojama) + Left align text Poravnaj tekst levo + Length: Dužina: + Level Nivo + Levels Nivoi + Lighten Posvetljenje + Lightness Svetlina + Lights Svetli tonovi + Limits: Granice: + Linear Gradient Linearni gradijent + Link layers Poveži slojeve + Link width and height Poveži širinu i visinu + Liquify is not open Tečno izobličavanje nije otvoreno + Load Učitaj + Load 3D LUT… Učitaj 3D LUT… + Load channel as selection Učitaj kanal kao selekciju + Load… Učitaj… + Localized Color Clusters Lokalizovane grupe boja + Lock Zaključaj + Lock all Zaključaj sve + Lock image pixels Zaključaj piksele slike + Lock layer Zaključaj sloj + Lock position Zaključaj položaj + Lock transparent pixels Zaključaj providne piksele + Lock: Zaključaj: + Logical Length: Logička dužina: + Logical Units: Logičke jedinice: + Luminance Luminansa + Luminance Detail Detalji luminanse + Magenta Magenta + Magenta · Green Magenta · zelena + Maintain aspect ratio Zadrži odnos stranica + Marker Size Veličina markera + Mask All Maskiraj sve + Mask Options Opcije maske + Masking Maskiranje + Master Glavno + Matte Podloga + Median: Medijana: + Menus Meniji + Mesh Size Veličina mrežice + Metadata Metapodaci + Metrics Metrika + Midpoint Središnja tačka + Midtones Srednji tonovi + Midtones from: Srednji tonovi od: + Midtones to: Srednji tonovi do: + Minimum Depth Minimalna dubina + Minimum Diameter Minimalni prečnik + Minimum Roundness Minimalna zaobljenost + Mode: Režim: + Monochrome Jednobojno + More Points Više tačaka + Move effect layer down Pomeri sloj efekta nadole + Move effect layer up Pomeri sloj efekta nagore + Move to Premesti u + Name Ime + Name: Ime: + Nearest Neighbor Najbliži sused + Nearest Neighbor (hard edges) Najbliži sused (oštre ivice) + New Size Nova veličina + New document… Novi dokument… + New effect layer Novi sloj efekta + New selection Nova selekcija + Next frame Sledeći kadar + Next note Sledeća beleška + No Color Bez boje + No Color Management Bez upravljanja bojama + No Dither Bez diterovanja + No author Nema autora + No character style Bez stila znakova + No document Nema dokumenta + No matching commands Nema odgovarajućih komandi + No presets for the current tool. Nema šablona za tekuću alatku. + No presets of this type. Nema šablona ove vrste. + No properties Nema svojstava + No tool presets. Nema šablona alatki. + Noise Reduction Smanjenje šuma + Normal Normalno + Not in Dictionary: Nije u rečniku: + OK U redu + Offset Pomak + Only Web Colors Samo veb boje + Opacity Neprozirnost + Opacity Jitter Kolebanje neprozirnosti + Opacity: Neprozirnost: + Open a document to define variables. Otvorite dokument da biste definisali promenljive. + Open a document to select a colour range. Otvorite dokument da biste izabrali opseg boja. + Open a document to use the timeline. Otvorite dokument da biste koristili vremensku liniju. + Optical Optički + Optimized Optimizovano + Option-click to define a source point to clone from Option-klik da odredite izvornu tačku za kloniranje + Original Original + Output Channel: Izlazni kanal: + Output Levels: Izlazni nivoi: + Output: Izlaz: + Outside Spolja + Overlay Color Boja preklopa + Overlay Opacity Neprozirnost preklopa + Overlay: Rule of Thirds Preklop: pravilo trećina + Override Pressure Nadjačaj pritisak + Override Rotation Nadjačaj rotaciju + Override Tilt Nadjačaj nagib + PRESET DETAILS DETALJI ŠABLONA + PSD PSD + Paint Overlay Preklop za slikanje + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Položaji panela se čuvaju u radnom prostoru. Prečice na tastaturi, meniji i traka alatki su opcioni. + Paper Papir + Path Putanja + Pattern Šara + Pattern Size Veličina šare + Pause Pauza + Percent Procenat + Perceptual Perceptivno + Perspective Warp is not active Perspektivno krivljenje nije aktivno + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivno krivljenje: nacrtajte ravni duž perspektive slike, a zatim pređite na krivljenje + Photo Filter Foto-filter + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft upravlja bojama + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — izvorni uređivač slika otvorenog koda, napisan u Rust-u. + Pin Depth: Dubina čiode: + Pixel Length: Dužina u pikselima: + Pixels/Centimeter Piksela/centimetar + Pixels/Inch Piksela/inč + Pixels: Pikseli: + Play Pusti + Play selection Pusti izabrano + Portrait Uspravno + Position Položaj + Position and Size Položaj i veličina + Posterize Posterizacija + Preserve Details Očuvaj detalje + Preserve Luminosity Očuvaj osvetljenje + Preset Šablon + Preset Type Vrsta šablona + Preset: Šablon: + Presets: Šabloni: + Press keys… Pritisnite tastere… + Pressure Pritisak + Pressure for Opacity Pritisak za neprozirnost + Pressure for Size Pritisak za veličinu + Prevent auto-nesting in and out of Artboards and Frames Spreči automatsko ugnežđavanje u radne table i okvire i izvlačenje iz njih + Preview Pregled + Previous frame Prethodni kadar + Previous note Prethodna beleška + Print Štampaj + Printer Štampač + Printer Manages Colors Štampač upravlja bojama + Printer Setup Podešavanje štampača + Printing Marks Oznake za štampu + Progressive Progresivno + Promote Unapredi + Protect Detail Zaštiti detalje + Protect Foreground Color Zaštiti boju prednjeg plana + Protect Tones Zaštiti tonove + Proximity Match Podudaranje po blizini + Pulled String Mode Režim zategnute niti + Puppet Warp is not active Lutkarsko krivljenje nije aktivno + Purity Čistoća + Quality Kvalitet + Quick Actions Brze radnje + Quick Export As PNG Brzi izvoz u PNG + Radial Gradient Radijalni gradijent + Radius Poluprečnik + Radius: Poluprečnik: + Range: Opseg: + Rate Brzina + Recently Used Nedavno korišćeno + Reconstruct Rekonstruiši + Record ● a sequence of edits, then play ▶ it on any document. Snimite ● niz izmena, a zatim ga pustite ▶ na bilo kom dokumentu. + Red Crvena + Redefine style by current selection Redefiniši stil prema tekućoj selekciji + Refine Doteraj + Reflected Gradient Reflektovani gradijent + Registration Marks Oznake registracije + Regular Obično + Relative Relativno + Relative Colorimetric Relativno kolorimetrijski + Remove Ukloni + Remove All Pins Ukloni sve čiode + Remove Background Ukloni pozadinu + Remove Planes Ukloni ravni + Remove Tool Alatka za uklanjanje + Remove stop Ukloni tačku + Remove variable Ukloni promenljivu + Rename Preimenuj + Rename Group… Preimenuj grupu… + Rename Layer… Preimenuj sloj… + Rename Tool Preset… Preimenuj šablon alatke… + Rename… Preimenuj… + Resample Ponovo uzorkuj + Reset All to Defaults Vrati sve na podrazumevano + Import Shortcuts… Uvezi prečice… + Load a keyboard shortcut set saved as .kys Učitaj skup prečica sačuvan kao .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Uvezeno {n} prečica iz {file}. Kliknite U redu da ih zadržite. + {n} commands are not in PhotoCraft. {n} komandi ne postoji u PhotoCraft-u. + Keyboard shortcuts imported Prečice na tastaturi su uvezene + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Ovde se koristi vaš skup prečica {set}: {n} prečica se razlikuje od podrazumevanih. Uređivanje › Prečice na tastaturi ih prikazuje; Vrati sve na podrazumevano poništava ovo. + Import keyboard shortcuts? Uvesti prečice na tastaturi? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft je na ovom računaru pronašao vaš skup prečica {set} iz drugog uređivača slika: {n} prečica se razlikuje od PhotoCraft-ovih. Uvesti ga? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Uređivanje › Prečice na tastaturi › Vrati sve na podrazumevano poništava uvoz. + Don't Import Nemoj uvesti + The keyboard shortcut set was not imported: {error} Skup prečica nije uvezen: {error} + Reset Section Resetuj odeljak + Reset Transform Resetuj transformaciju + Reset to defaults Vrati na podrazumevano + Resolution Rezolucija + Resolution: Rezolucija: + Restore All Vrati sve + Restore Defaults Vrati podrazumevano + Restrictive (Web) Restriktivno (veb) + Right align text Poravnaj tekst desno + Rigid Kruto + Rotate: Rotacija: + Rotation Rotacija + Roughness Hrapavost + Roundness Zaobljenost + Roundness Jitter Kolebanje zaobljenosti + Rulers & Grids Lenjiri i mreže + Sample All Layers Uzorkuj sve slojeve + Sample: Uzorak: + Sampling: Background Swatch Uzorkovanje: uzorak pozadine + Sampling: Continuous Uzorkovanje: neprekidno + Sampling: Once Uzorkovanje: jednom + Saturate Zasiti + Saturation Zasićenost + Saturation Jitter Kolebanje zasićenosti + Save HTML and Images Sačuvaj HTML i slike + Save as PDF Sačuvaj kao PDF + Save selection as channel Sačuvaj selekciju kao kanal + Save… Sačuvaj… + Scale to Fit Media Skaliraj prema medijumu + Scatter Rasejavanje + Scratch disks Radni diskovi + Search Pretraga + Search commands Pretraži komande + Search commands, tools and panels… Pretraži komande, alatke i panele… + Search fonts Pretraži fontove + Select All Izaberi sve + Select Subject Izaberi subjekat + Select a type layer or click into text to insert glyphs Izaberite tekstualni sloj ili kliknite u tekst da umetnete glifove + Select text to apply a character style Izaberite tekst da primenite stil znakova + Select: Izaberi: + Selections Selekcije + Selective Selektivno + Selective Color Selektivna boja + Send the selected pin backward Pomeri izabranu čiodu nazad + Set additional smoothing options Podesi dodatne opcije izglađivanja + Set background color Postavi boju pozadine + Set foreground color Postavi boju prednjeg plana + Set painting symmetry options Podesi opcije simetrije slikanja + Symmetry Off Simetrija isključena + Set shape fill type Podesi vrstu popune oblika + Set shape stroke type Podesi vrstu obruba oblika + Set the text color Postavi boju teksta + Shadows Senke + Shape: Oblik: + Sharpening Izoštravanje + Shift Shift + Shortcut removed. Prečica je uklonjena. + Show All Menu Items Prikaži sve stavke menija + Show Backdrop Prikaži podlogu + Show Guides Prikaži vodilje + Show Mask Prikaži masku + Show Mesh Prikaži mrežicu + Show Overlay Prikaži preklop + Show Transform Controls Prikaži kontrole transformacije + Show or hide the Notes panel Prikaži ili sakrij panel Beleške + Show: Prikaži: + Sides Stranice + Sides: Stranice: + Size Veličina + Size Jitter Kolebanje veličine + Size: Veličina: + Slice Options… Opcije isečka… + Slices From Guides Isečci od vodilja + Slices are locked (View › Lock Slices) Isečci su zaključani (Prikaz › Zaključaj isečke) + Small Caps Kapitalke + Smoothing Izglađivanje + Space After Razmak posle + Space Before Razmak pre + Spacing Razmak + Spell check complete. Provera pravopisa je završena. + Split: Podela: + Star ratio Odnos zvezde + Stop playing/recording Zaustavi puštanje/snimanje + Straighten Ispravi + Straighten Layer Ispravi sloj + Strength: Jačina: + Strikethrough Precrtano + Stroke Obrub + Stroke Catch-up Sustizanje poteza + Stroke: Obrub: + Location: Položaj: + Style Stil + Style Options Opcije stila + Style Options… Opcije stila… + Style: Normal Stil: normalno + Subtract from Sample Oduzmi od uzorka + Subtract from selection ({key}) Oduzmi od selekcije ({key}) + Suggestions: Predlozi: + Swap colours (X) Zameni boje (X) + Swaps height and width Zamenjuje visinu i širinu + Switch between free transform and warp modes Prebaci između režima slobodne transformacije i krivljenja + Switch theme Promeni temu + Temperature Temperatura + Tetrahedral Tetraedarski + Text Position: Položaj teksta: + Texture Tekstura + Texture Each Tip Tekstura za svaki vrh + Threshold Prag + Threshold Level Nivo praga + Thumbnail size (smallest: list) Veličina sličica (najmanja: spisak) + Tilt X Nagib X + Tilt Y Nagib Y + Tint Preliv + Toggle count group visibility Uključi/isključi vidljivost grupe brojanja + Toggle text orientation Promeni orijentaciju teksta + Tolerance Tolerancija + Tolerance: Tolerancija: + Tone: Ton: + Toolbar Traka alatki + Top Gore + Total: Ukupno: + Tracking Razmak između slova + Tracking (1/1000 em) Razmak između slova (1/1000 em) + Transparency Providnost + Transparent Providno + Trilinear Trilinearno + Type Styles Stilovi teksta + Type: Vrsta: + Underline Podvučeno + Untitled Bez naslova + Untitled-1 Bez naslova-1 + Update layer comp (visibility, position and appearance) Ažuriraj kompoziciju slojeva (vidljivost, položaj i izgled) + 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 + View Options Opcije prikaza + Vignetting Vinjetiranje + Violet, Orange Ljubičasta, narandžasta + Visibility Vidljivost + Visible Vidljivo + Warp: Krivljenje: + Web Snap Prianjanje uz veb boje + Weight Debljina + Weight: Debljina: + White Bela + White Balance: As Shot Balans bele: kao pri snimanju + Whites Bele + Width Širina + Slant Iskošenost + Width: Širina: + Work Path Radna putanja + Yellow Žuta + Yellow · Blue Žuta · plava + Zoom Zum + Zoom in Uvećaj + Zoom out Umanji + command or shortcut komanda ili prečica + current tekuće + image file path putanja datoteke slike + modified izmenjeno + name ime + new novo + {name} options Opcije: {name} + ↑↓ navigate {key} run esc close ↑↓ kretanje {key} pokreni esc zatvori + Version {version} Verzija {version} + New Document Novi dokument + New Document With Mask Novi dokument sa maskom + New Layer With Mask Novi sloj sa maskom + New Layer Novi sloj + Preset Name Ime šablona + No saved presets yet. Još nema sačuvanih šablona. + Error Greška + Command Komanda + Adjustment layers have nothing to transform Korekcioni slojevi nemaju ništa što bi se transformisalo + Altitude Visina + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45 mm + Aqua Akva + Art & Illustration Umetnost i ilustracija + ArtCraft ArtCraft + Background Eraser Tool Alatka gumica za pozadinu + Bevel & Emboss Kosina i reljef + Blend Mode Režim stapanja + Blues Plave + Blur Tool Alatka za zamućivanje + Bold Podebljano + Brush Četkica + Block Blok + Brush Pose Položaj četkice + Brush Tip Shape Oblik vrha četkice + Brush Tool Alatka četkica + Build-up Nagomilavanje + Burn Tool Alatka za zatamnjivanje + Centimeters Centimetri + Choke Sužavanje + Classic Klasično + Click to add text Kliknite da dodate tekst + Click to fill similar colours Kliknite da popunite slične boje + Clone Stamp Tool Alatka pečat za kloniranje + Color Burn Zatamnjivanje boje + Color Dodge Posvetljivanje boje + Color Dynamics Dinamika boje + Color Overlay Prekrivanje bojom + Comic Book Strip + Conform Usaglašeno + Content-Aware Fill needs a selection on a pixel layer Popuna prema sadržaju zahteva selekciju na piksel sloju + Could not transform: the layer is empty Transformacija nije moguća: sloj je prazan + Could not use Perspective Warp: the layer is empty Perspektivno krivljenje nije moguće: sloj je prazan + Could not use Puppet Warp: no opaque pixels Lutkarsko krivljenje nije moguće: nema neprozirnih piksela + Could not use Puppet Warp: the layer is empty Lutkarsko krivljenje nije moguće: sloj je prazan + Crop Tool Alatka za opsecanje + Crosswise Unakrsno + Current Tool Tekuća alatka + Custom Shape Tool Alatka za prilagođeni oblik + Cyans Cijan + Default PhotoCraft Size Podrazumevana veličina PhotoCraft-a + Delete Workspace Obriši radni prostor + Diamond Romb + Dimensions: Dimenzije: + Direction Smer + Distance Udaljenost + Document Dimensions Dimenzije dokumenta + Document Profile Profil dokumenta + Document Sizes Veličine dokumenta + Dodge Tool Alatka za posvetljivanje + Down Nadole + Drag to draw a gradient Prevucite da nacrtate gradijent + Drag to move the active layer Prevucite da pomerite aktivni sloj + Drag to pan · hold Space with any tool Prevucite da pomerate prikaz · držite razmaknicu uz bilo koju alatku + Drop Shadow Bačena senka + Dual Brush Dvostruka četkica + Edge Ivica + Efficiency Efikasnost + Efficiency: 100% Efikasnost: 100% + Ellipse Tool Alatka elipsa + Elliptical Marquee Tool Alatka za eliptičnu selekciju + Single Row Marquee Tool Alatka za selekciju jednog reda + Single Column Marquee Tool Alatka za selekciju jedne kolone + Emboss Reljef + Eraser Gumica + Eraser Tool Alatka gumica + ExtraBold Ekstra podebljano + ExtraLight Ekstra lagano + Eyedropper Tool Alatka pipeta + Fade Iščezavanje + Fill Opacity Neprozirnost popune + Film & Video Film i video + From Od + General Opšte + GitHub GitHub + Gradient Overlay Prekrivanje gradijentom + Gradient Tool Alatka gradijent + Greens Zelene + Hand Tool Alatka ruka + Hard Tvrdo + Hard Mix Oštro mešanje + Healing Brush Tool Alatka četkica za popravku + Highlights from: Svetla od: + History Brush Tool Alatka četkica istorije + Horizontal Type Tool Alatka za horizontalni tekst + Horizontal Type Mask Tool Alatka za horizontalnu masku teksta + Vertical Type Mask Tool Alatka za vertikalnu masku teksta + Image Size: Veličina slike: + Inches Inči + Indexed Indeksirano + Initial Direction Početni smer + Inner Bevel Unutrašnja kosina + Inner Glow Unutrašnji sjaj + Inner Shadow Unutrašnja senka + Lab Lab + Landscape, 10 x 8 Položeno, 10 x 8 + Landscape, 6 x 4 Položeno, 6 x 4 + Landscape, 7 x 5 Položeno, 7 x 5 + Lasso Laso + Lasso Tool Alatka laso + Layer Count Broj slojeva + Layer Knocks Out Drop Shadow Sloj izbija bačenu senku + Legal Legal + Letter Letter + Light Svetlo + Line Tool Alatka linija + Linear Linearno + Linear Burn Linearno zatamnjivanje + Linear Height Linearna visina + Link with Layer Poveži sa slojem + Lorem Ipsum Lorem Ipsum + Magentas Magenta + Magic Eraser Tool Alatka čarobna gumica + Magic Wand Tool Alatka čarobni štapić + Marquee Okvirna selekcija + Mean: Srednja vrednost: + Measurement Scale Razmera merenja + Measurement Scale Marker Oznaka razmere merenja + Millimeters Milimetri + Mobile Mobilni + Move Tool Alatka za pomeranje + Could not use the move tool because the layer is locked. Alatka za pomeranje ne može da se koristi jer je sloj zaključan. + Could not complete the Free Transform command because the layer is locked. Komanda Slobodna transformacija ne može da se izvrši jer je sloj zaključan. + Multiply Množenje + My Brushes Moje četkice + Neutrals Neutralne + New Workspace Novi radni prostor + Note Tool Alatka za beleške + Object Selection Tool Alatka za selekciju objekta + Off Isključeno + Open Otvori + Orange Narandžasta + Original Ratio Originalni odnos + Out Of Gamut Van gamuta + Outer Bevel Spoljašnja kosina + Outer Glow Spoljašnji sjaj + Overlay Preklapanje + Paint Bucket Tool Alatka kofica s bojom + Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju + Path Selection Tool Alatka za selekciju putanje + Direct Selection Tool Alatka za direktnu selekciju + Add Anchor Point Tool Alatka za dodavanje sidrišne tačke + Delete Anchor Point Tool Alatka za brisanje sidrišne tačke + Click a segment to add an anchor point · {alt}-click an anchor to delete it Kliknite segment da dodate sidrišnu tačku · {alt}-klik na tačku da je obrišete + Click an anchor point to delete it · {alt}-click a segment to add one Kliknite sidrišnu tačku da je obrišete · {alt}-klik na segment da dodate novu + Pattern Overlay Prekrivanje šarom + Pen Pressure Pritisak olovke + Pen Tilt Nagib olovke + Pen Tool Alatka pero + Convert Point Tool Alatka za pretvaranje tačke + Photo Fotografija + PhotoCraft website Veb-sajt PhotoCraft-a + Picas Pike + Pillow Emboss Jastučasti reljef + Pixel Replacement Zamena piksela + Pixels Pikseli + Points Tačke + Polygon Tool Alatka mnogougao + Polygonal Lasso Tool Alatka mnogougaoni laso + Portrait, 4 x 6 Uspravno, 4 x 6 + Portrait, 5 x 7 Uspravno, 5 x 7 + PostScript (72 points/inch) PostScript (72 tačke/inč) + Postcard Razglednica + Poster Poster + Pro (Dark) Pro (tamna) + Pro (Light) Pro (svetla) + Pro (Light Gray) Pro (svetlosiva) + Pro (Medium Gray) Pro (srednje siva) + Protect Texture Zaštiti teksturu + Purple Ljubičasta + Quick Mask Brza maska + Quick Selection Tool Alatka za brzu selekciju + Radial Radijalno + Rasterize Layer Rasterizuj sloj + Rasterize Layers Rasterizuj slojeve + Ratio Odnos + Recent Nedavno + Rectangle Tool Alatka pravougaonik + Rectangular Marquee Tool Alatka za pravougaonu selekciju + Reds Crvene + Reflected Reflektovano + Sample: foreground colour Uzorak: boja prednjeg plana + Sampled Colors Uzorkovane boje + Satin Saten + Save Pixel Aspect Ratio Sačuvaj odnos stranica piksela + Scattering Rasejavanje + Scratch Sizes Veličine radnog diska + Select Data Points Izaberi tačke podataka + Selection Selekcija + SemiBold Polupodebljano + Shadows up to: Senke do: + Shape Dynamics Dinamika oblika + Sharpen Tool Alatka za izoštravanje + Show Extras Options Opcije prikaza dodatnih elemenata + Slice Select Tool Alatka za izbor isečka + Slice Tool Alatka za isecanje na isečke + Smart Filters Pametni filteri + Smudge Tool Alatka za razmazivanje + Soft Meko + Soften Omekšaj + Source Izvor + Source: set Izvor: postavljen + Sponge Tool Alatka sunđer + Spot Healing Brush Tool Alatka četkica za tačkastu popravku + Spread Raširenost + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Std Dev: Std. devijacija: + Studio (Dark) Studio (tamna) + Studio (Light) Studio (svetla) + Stylus Wheel Točkić olovke + Subtract Oduzimanje + Tabloid Tabloid + Text Replacement Zamena teksta + Thin Tanko + Traditional (72.27 points/inch) Tradicionalno (72,27 tačaka/inč) + Transfer Prenos + Triangle Tool Alatka trougao + Unsaved changes Nesačuvane izmene + Uses the foreground and background colours. Koristi boje prednjeg plana i pozadine. + Web Veb + Web Large Veb, veliko + Web Minimum Veb, minimum + Web Most Common Veb, najčešće + WebP (lossless) WebP (bez gubitaka) + Wet Edges Vlažne ivice + Yellows Žute + Zoom Tool Alatka lupa + {name} Properties Svojstva: {name} + Revert “{name}” to the last saved version? Your changes will be lost. Vratiti „{name}“ na poslednju sačuvanu verziju? Vaše izmene će biti izgubljene. + Do you want to save the changes you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Do you want to save the changes you made to “{name}” before closing? Želite li da sačuvate izmene u „{name}“ pre zatvaranja? + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Radni disk: {size} + Untagged {mode} {mode} bez oznake profila + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Rezolucija: {dpi} piksela/inč + Doc: {w} × {h} px Dok.: {w} × {h} px + Frame {current} / {total} Kadar {current} / {total} + Count: {n} Broj: {n} + BLANK DOCUMENT PRESETS ({n}) ŠABLONI PRAZNIH DOKUMENATA ({n}) + Constraints ({n}) Ograničenja ({n}) + Align {what} Poravnaj: {what} +@plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva +@plural {n} layer|{n} layers {n} sloj|{n} sloja|{n} slojeva +@plural Group · {n} layer|Group · {n} layers Grupa · {n} sloj|Grupa · {n} sloja|Grupa · {n} slojeva + Additional plugins folder Dodatna fascikla za dodatke + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Dozvoli upravljanje agentima + Allow scripts to connect Dozvoli skriptama da se povežu + Always Uvek + Always create smart objects when placing Uvek pravi pametne objekte pri postavljanju + Animated zoom Animirano zumiranje + Apply auto tone Primeni automatski ton + Ask Pitaj + Ask before saving layered tiff Pitaj pre čuvanja TIFF-a sa slojevima + Auto collapse icon panels Automatski skupi panele sa ikonama + Auto show hidden panels Automatski prikaži skrivene panele + Auto show home screen Automatski prikaži početni ekran + Autosave Automatsko čuvanje + Autosave minutes Automatsko čuvanje (minuti) + Beep when done Zvučni signal po završetku + Bicubic automatic Bikubično automatski + Bicubic sharper Bikubično oštrije + Bicubic smoother Bikubično glađe + Bit depth Dubina bita + Brush preview color Boja pregleda četkice + Cache levels Nivoi keša + Cache tile size Veličina pločice keša + Camera Raw Defaults Podrazumevana Camera Raw podešavanja + Canvas border Ivica platna + Show canvas dots Prikaži tačke na platnu + Canvas color Boja platna + Canvas custom color Prilagođena boja platna + Color picker Birač boja + Color space Prostor boja + Column width Širina kolone + Concise Sažeto + Control port Kontrolni port + Convert to srgb Pretvori u sRGB + Cursors Kursori + Custom dark Prilagođena tamna + Custom light Prilagođena svetla + Dark Tamna + Dark gray Tamnosiva + Dashed lines Isprekidane linije + Destination Odredište + Detailed Detaljno + Disks Diskovi + Display p3 Display P3 + Double click layer mask launches select and mask Dvoklik na masku sloja otvara Izaberi i maskiraj + Drop shadow Bačena senka + Dynamic color sliders Dinamički klizači boja + East asian Istočnoazijsko + Effect cache mb Keš efekata (MB) + Enable flick panning Omogući pomeranje prikaza zamahom + Enable floating document window docking Omogući usidravanje plutajućih prozora dokumenata + Enable narrow options bar Omogući usku traku opcija + Enabled Omogućeno + Enhanced Controls Napredne kontrole + Export clipboard Izvezi ostavu + Extra large Veoma velika + File Handling Rukovanje datotekama + File path Putanja datoteke + Fill new type layers with placeholder Popuni nove tekstualne slojeve probnim tekstom + Font preview Pregled fonta + Full size tip Vrh pune veličine + Gamut warning color Boja upozorenja o gamutu + Gamut warning opacity Neprozirnost upozorenja o gamutu + Generator enabled Generator je omogućen + Gif GIF + Glossy Sjajno + Grid color Boja mreže + Grid colors Boje mreže + Grid size Veličina mreže + Grid style Stil mreže + Grid unit Jedinica mreže + Gridline every Linija mreže na svakih + Guide color Boja vodilja + Guide style Stil vodilja + Guides, Grid & Slices Vodilje, mreža i isečci + Gutter Žleb + History Log Dnevnik istorije + History states Stanja istorije + Ignore exif profile tag Zanemari EXIF oznaku profila + Image interpolation Interpolacija slike + Image previews Pregledi slika + Integrations Integracije + Interface Interfejs + Jpeg quality JPEG kvalitet + Jpg JPG + Language Jezik + Large tabs Velike kartice + Legacy compositing Staro komponovanje + Light gray Svetlosiva + Line Linija + Lines Linije + Lossless Bez gubitaka + Lowercase extension Ekstenzija malim slovima + Maximize psd compatibility Maksimalna kompatibilnost PSD-a + Medium gray Srednje siva + Memory usage mb Korišćenje memorije (MB) + Low resolution previews Pregledi niske rezolucije + Missing glyph protection Zaštita od glifova koji nedostaju + Modern Moderno + Nearest neighbor Najbliži sused + Never Nikad + Normal tip Normalan vrh + Open as smart object Otvori kao pametni objekat + Open in camera raw Otvori u Camera Raw-u + Open documents as tabs Otvaraj dokumente kao kartice + Overscroll Pomeranje preko ivice + Performance Performanse + Png PNG + Point size Veličina tačke + Precise Precizno + Preserve details Očuvaj detalje + Print resolution Rezolucija štampe + Pro Pro + Pro light Pro svetla + Pro light gray Pro svetlosiva + Pro medium Pro srednja + Pro photo ProPhoto + Quick export format Format brzog izvoza + Quick export location Lokacija brzog izvoza + Recent file count Broj nedavnih datoteka + Recent files Nedavne datoteke + Recent fonts Nedavni fontovi + Recover on launch Oporavi pri pokretanju + Remember workspace changes Zapamti izmene radnog prostora + Resize image during place Promeni veličinu slike pri postavljanju + Rotate view with trackpad Rotiraj prikaz pomoću dodirne table + Same folder Ista fascikla + Save in background Čuvaj u pozadini + Scratch Disks Radni diskovi + Screen Ekran + Screen resolution Rezolucija ekrana + Scrubby slider acceleration Ubrzanje klizača prevlačenjem + Sessions only Samo sesije + Sharpen for Izoštri za + Show channels in color Prikaži kanale u boji + Show crosshair in brush tip Prikaži krstić u vrhu četkice + Show extension panels Prikaži panele proširenja + Show font names in english Prikaži imena fontova na engleskom + Show menu colors Prikaži boje menija + Show only crosshair while painting Prikaži samo krstić tokom slikanja + Show slice numbers Prikaži brojeve isečaka + Show tooltips Prikaži opise alatki + Enable animations Omogući animacije + Show highlight on rollover Istakni pri prelasku pokazivačem + Show transformation values Prikaži vrednosti transformacije + Slice color Boja isečka + Smart guide color Boja pametnih vodilja + Smart quotes Pametni navodnici + Snap vector tools and transforms to pixel grid Prianjaj vektorske alatke i transformacije uz mrežu piksela + Srgb sRGB + Standard Standardno + Studio Studio + Studio light Studio svetla + Adwaita Adwaita + Adwaita dark Adwaita tamna + Subdivisions Pododeljci + System Sistem + Text engine Mašina za tekst + Text file Tekstualna datoteka + Tiny Sićušno + Touch gestures Pokreti na dodirnom ekranu + Transparency & Gamut Providnost i gamut + Type units Jedinice teksta + UI font size Veličina fonta interfejsa + UI scale Razmera interfejsa + Units & Rulers Jedinice i lenjiri + Use additional plugins folder Koristi dodatnu fasciklu za dodatke + Use esc to commit Koristi Esc za potvrdu + Use gpu Koristi GPU + Use legacy free transform Koristi staru slobodnu transformaciju + Use shift key for tool switch Koristi Shift za promenu alatke + Vary round brush hardness on hud Menjaj tvrdoću okrugle četkice na HUD-u + Webp WebP + World ready Za sva pisma sveta + Zoom clicked point to center Zumiraj tako da kliknuta tačka bude u centru + Zoom resizes windows Zumiranje menja veličinu prozora + Zoom with scroll wheel Zumiraj točkićem miša + Zoom with trackpad pinch Zumiraj štipanjem na dodirnoj tabli + Pass Through Propuštanje + Dissolve Rastapanje + Darker Color Tamnija boja + Linear Dodge (Add) Linearno posvetljivanje (sabiranje) + Lighter Color Svetlija boja + Soft Light Meko svetlo + Hard Light Oštro svetlo + Vivid Light Živo svetlo + Linear Light Linearno svetlo + Pin Light Tačkasto svetlo + Exclusion Isključenje + Divide Deljenje + Luminosity Svetlina tona +@plural {n} sample|{n} samples {n} uzorak|{n} uzorka|{n} uzoraka + {added}, {removed} subtracted {added}, oduzeto: {removed} + Adjustment Preview Pregled korekcije + Airbrush: keeps depositing paint while the pointer rests. Vazdušna četkica: nastavlja da nanosi boju dok pokazivač miruje. + Angle: Ugao: + Blend If Stapaj ako + Close Tab Group Zatvori grupu kartica + Collapse Skupi + Collapse All Groups Skupi sve grupe + Collapse Panel Group Skupi grupu panela + Collapse group ({key}-click: all groups) Skupi grupu ({key}-klik: sve grupe) + Create new brush from the current settings Napravi novu četkicu od tekućih podešavanja + Create new brush from these settings Napravi novu četkicu od ovih podešavanja + Update the current brush with these settings Ažuriraj tekuću četkicu ovim podešavanjima + Delete brush Obriši četkicu + Drag the arrow to set the angle, the dots to set the roundness Prevucite strelicu da podesite ugao, a tačke da podesite zaobljenost + Expand Proširi + Expand Panel Group Proširi grupu panela + Expand group ({key}-click: all groups) Proširi grupu ({key}-klik: sve grupe) + Fade steps Koraci iščezavanja + Hide the layer's effects ({key}-click: all layers) Sakrij efekte sloja ({key}-klik: svi slojevi) + Import Brushes… (.abr) Uvezi četkice… (.abr) + Move Group Down Pomeri grupu nadole + Move Group Up Pomeri grupu nagore + Reset the brush to the defaults Vrati četkicu na podrazumevano + Restore original size Vrati originalnu veličinu + Roundness: Zaobljenost: + Search Brushes Pretraži četkice + Show the layer's effects ({key}-click: all layers) Prikaži efekte sloja ({key}-klik: svi slojevi) + Brush picker Birač četkica + Erase Briši + Right click with painting tools Desni klik alatkama za slikanje + System Info… Informacije o sistemu… + System Info Informacije o sistemu + More panels Više panela + More documents Više dokumenata + These settings aren't available in PhotoCraft yet. Ova podešavanja još nisu dostupna u PhotoCraft-u. + Graphics Grafika + Reset GPU Backend Resetuj GPU pozadinski sistem + Applies at next launch. Primenjuje se pri sledećem pokretanju. + Click to sample the foreground colour · {key}-click for background Kliknite da uzorkujete boju prednjeg plana · {key}-klik za pozadinu + {key}-click to set the source {key}-klik da postavite izvor + {key} to subtract {key} za oduzimanje + Clone source {n} ({key}-click with Clone Stamp to set) Izvor kloniranja {n} ({key}-klik pečatom za kloniranje da postavite) + Source: not set ({key}-click with Clone Stamp) Izvor: nije postavljen ({key}-klik pečatom za kloniranje) + Pixel Layer Piksel sloj + Type Layer Tekstualni sloj + Shape Layer Sloj oblika + Group Layer Sloj grupe + Fill Layer Sloj popune + Smart Object Layer Sloj pametnog objekta + Create Warped Text Napravi iskrivljeni tekst + Rasterize Smart Object Rasterizuj pametni objekat + Rasterize Fill Content Rasterizuj sadržaj popune + Rasterize Layer Style Rasterizuj stil sloja + Unlock: picking a preset replaces these settings Otključaj: izbor šablona zamenjuje ova podešavanja + Lock: keep these settings when picking another preset Zaključaj: zadrži ova podešavanja pri izboru drugog šablona + Diameter: Prečnik: + Hardness: Tvrdoća: + No brush presets Nema šablona četkica + Toggle the Brush Settings panel (F5) Prikaži/sakrij panel Podešavanja četkice (F5) + Create new brush preset from the current settings Napravi novi šablon četkice od tekućih podešavanja + Brush Settings… (F5): every option of this brush Podešavanja četkice… (F5): sve opcije ove četkice + Rename Brush… Preimenuj četkicu… + Delete Brush Obriši četkicu + New Brush Preset… Novi šablon četkice… + Brush Preset Options Opcije šablona četkice + Group name Ime grupe + Brush name Ime četkice + Brush Name Ime četkice + Brush Stroke Potez četkicom + Off: the speed of the pointer sets the spacing Isključeno: brzina pokazivača određuje razmak + Tilt Scale Razmera nagiba + Brush Projection Projekcija četkice + Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha + Mixer Brush Četkica za mešanje + Wet Vlažnost + Mix Mešanje + Wetness Jitter Kolebanje vlažnosti + Mix Jitter Kolebanje mešanja + Show the layer mask as an overlay Prikaži masku sloja kao preklop + Toggle visibility Uključi/isključi vidljivost + Unlink the mask from the layer Razveži masku od sloja + Link the mask to the layer Poveži masku sa slojem + Gradient Gradijent + Gradient Options Opcije gradijenta + Align with layer Poravnaj sa slojem + Reset Alignment Resetuj poravnanje + Centre the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Uređivač gradijenta + Gradient picker Birač gradijenta + Save As Preset Sačuvaj kao šablon + The gradient the Gradient tool paints with Gradijent kojim slika alatka gradijent + Adds the current gradient to the Gradients panel Dodaje tekući gradijent u panel Gradijenti + Gradient stops Tačke gradijenta + Select a type layer to edit its character and paragraph settings. Izaberite tekstualni sloj da biste uredili podešavanja znakova i pasusa. + Text color Boja teksta + Type Options Opcije teksta + Anti-aliasing method Metod uglađivanja ivica + Horizontal type Horizontalni tekst + Vertical type Vertikalni tekst + Fill path with foreground color Popuni putanju bojom prednjeg plana + Stroke path with brush Ocrtaj putanju četkicom + Load path as a selection Učitaj putanju kao selekciju + Make work path from selection Napravi radnu putanju od selekcije + Add vector mask Dodaj vektorsku masku + Create new path Napravi novu putanju + Delete path Obriši putanju + Delete vector mask Obriši vektorsku masku + CPU (no GPU acceleration) CPU (bez GPU ubrzanja) + DirectX 12 DirectX 12 + GPU backend GPU pozadinski sistem + Linux display server Linux server prikaza + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Koristi pritisak na tabletu + Vulkan Vulkan + Alt-click to define a source point to clone from Alt-klik da odredite izvornu tačku za kloniranje + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. + Cancel opening Otkaži otvaranje + Cancelled opening {name} Otkazano otvaranje: {name} + Developing {name} Razvijanje: {name} + Cancelled {label} Otkazano: {label} + Esc to cancel Esc za otkazivanje + Opened {name} Otvoreno: {name} + Opening {name} Otvaranje: {name} + Saved {name} Sačuvano: {name} + Saving {name} Čuvanje: {name} + Saving {percent} Čuvanje {percent} + A save to {name} is still running; try again when it finishes Čuvanje u {name} je još u toku; pokušajte ponovo kada se završi + {label} is writing the file and can no longer be cancelled {label}: datoteka se upisuje i više ne može da se otkaže + Opening {name}… Otvaranje: {name}… + Opening… Otvaranje… + Reading… Čitanje… + Working… Obrada… +@id select.all Izaberi sve +@id edit.purge.undo Podaci za opoziv + Delete Smart Filters Obriši pametne filtere + Enable Smart Filters Omogući pametne filtere + New Layer… Novi sloj… + Delete Layer… Obriši sloj… + Color Range Opseg boja + Select Similar Colors Izaberi slične boje + New Selection Nova selekcija + Adaptive Wide Angle Prilagodljivi široki ugao + Chalk & Charcoal Kreda i ugalj + Charcoal Ugalj + Chrome Hrom + Conté Crayon Konte krejon + Graphic Pen Grafičko pero + Halftone Pattern Polutonska šara + Note Paper Papir za beleške + Photocopy Fotokopija + Plaster Gips + Reticulation Retikulacija + Stamp Pečat + Torn Edges Pocepane ivice + Water Paper Vodeni papir + Canvas rotation Rotacija platna + Extensions Proširenja + Libraries Biblioteke + 3D 3D + Toolbars Trake alatki + Move Pomeranje + Wand Štapić + Pencil Olovka + Clone Stamp Pečat za kloniranje + Healing Popravka + Dodge Posvetljivanje + Burn Zatamnjivanje + Pen Pero + Hand Ruka + Sampler Uzorkivač + Note tool Alatka za beleške + Quick Selection Brza selekcija + Magnetic Lasso Magnetni laso + History Brush Četkica istorije + Art History Brush Umetnička četkica istorije + Pattern Stamp Pečat za šaru + Spot Healing Tačkasta popravka + Red Eye Crvene oči + Patch Zakrpa + Content-Aware Move Pomeranje prema sadržaju + Rotate View Rotiraj prikaz + Perspective Crop Perspektivno opsecanje + Color Replacement Zamena boje + Dodge/Burn Posvetljivanje/zatamnjivanje + Dynamics Dinamika + Tilt Nagib + Reset Resetuj + Defaults restored. Podrazumevana podešavanja su vraćena. + Stop Zaustavi + Create new set Napravi novi skup + Technology Previews Probne tehnologije + New Group Nova grupa + Layer Comp Kompozicija slojeva + Create new layer Napravi novi sloj + Group · {n} layer Grupa · slojeva: {n} + General Blend Opšte stapanje + Advanced Blending Napredno stapanje + RGB RGB + Alpha Alfa + Spot Channel Kanal spot boje + Working Gray Plate Radna siva ploča + HSL/Grayscale HSL/nijanse sive + Tone Curve Tonska kriva + Split Toning Podeljeno toniranje + Lens Corrections Korekcije objektiva + Camera Calibration Kalibracija fotoaparata + Snapshots Snimci stanja + Recovery Oporavak + Fill Light Svetlo popune + Convert to Grayscale Pretvori u nijanse sive + Sharpness Oštrina + Color Noise Reduction Smanjenje šuma boje + Luminance Smoothness Glatkoća luminanse + Make Proizvođač + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Žižna daljina (35 mm) + Aspect Ratio Odnos stranica + Time Vreme + Artist Umetnik + Enter description… Unesite opis… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industrijski standard + Uncoated CMYK Nepremazani CMYK + Newspaper Novinski papir + Working Spaces Radni prostori + Color Management Policies Pravila upravljanja bojama + Detect Faces Prepoznaj lica + Localize Color Clusters Lokalizuj grupe boja + Duplicated selection name Ime duplirane selekcije + Operation Operacija + New channel only Samo novi kanal + Masked Areas Maskirane oblasti + Selected Areas Izabrane oblasti + Spot Color Spot boja + Color indicates Boja označava + Create Snapshot Napravi snimak stanja + Delete Snapshot Obriši snimak stanja + New Snapshot Novi snimak stanja + Snapshot 1 Snimak stanja 1 + Step Forward Korak napred + Step Backward Korak nazad + Nonlinear History Nelinearna istorija + Set New Snapshot as Default Postavi novi snimak stanja kao podrazumevani + Automatically Create First Snapshot Automatski napravi prvi snimak stanja + Automatically Create New Snapshot When Saving Automatski napravi novi snimak stanja pri čuvanju + Show New Snapshot Dialog by Default Podrazumevano prikaži dijalog za novi snimak stanja + Clear History Obriši istoriju + Panel Options… Opcije panela… + View menu Meni prikaza + Keep view in sync Drži prikaz usklađenim + {zoom}% {zoom}% + Mouse Controls Kontrole mišem + Initial Direction of Stroke Početni smer poteza + Direction of Stroke Smer poteza + Humidity Vlažnost vazduha + Auto Clean Brush Automatski čisti četkicu + Spacing: Razmak: + Brush Tip Vrh četkice + New Layer from Brush Novi sloj od četkice + New Brush Nova četkica + New Tool Preset Novi šablon alatke + Auto-Leading Automatski prored + Scale Horizontally Skaliraj horizontalno + Scale Vertically Skaliraj vertikalno + Baseline Shift Pomak osnovne linije + Mac LCD Mac LCD + Mac Sharp Mac oštro + Mac Crisp Mac jasno + Mac Strong Mac snažno + Mac Smooth Mac glatko + Superscript Eksponent + Subscript Indeks + Ligature Ligatura + Alternative Alternativa + Roman Uspravno + Bold Italic Podebljani kurziv + Ultra Light Ultra lagano + Extra Light Ekstra lagano + Semi Bold Polupodebljano + Extra Bold Ekstra podebljano + Ultra Bold Ultra podebljano + Condensed Suženo + First Line Indent Uvlačenje prvog reda + Hanging Punctuation Viseća interpunkcija + Roman Hanging Punctuation Viseća interpunkcija za latinicu + Avoid Widows/Orphans Izbegavaj udovice/siročiće + Compose Slaganje + Adobe Single-line Composer Adobe slagač pojedinačnih redova + Adobe Every-line Composer Adobe slagač svih redova + Justification Obostrano poravnanje + Word Spacing Razmak između reči + Letter Spacing Razmak između slova + Glyph Scaling Skaliranje glifova + Auto-leading % Automatski prored % + Single Word Justification Obostrano poravnanje jedne reči + Alignment Poravnanje + Justify Poravnaj obostrano + Glyph Glif + Alternates for Current Selection Alternative za tekuću selekciju + Digits Cifre + Symbols Simboli + Punctuation Interpunkcija + Path: Putanja: + Path to Illustrator… Putanja u Illustrator… + Make Work Path Napravi radnu putanju + Make Selection Napravi selekciju + Fill Path Popuni putanju + Stroke Path Ocrtaj putanju + Clipping Path Putanja isecanja + New Path Nova putanja + Duplicate Path Dupliraj putanju + Delete Path Obriši putanju + Auto-Hide Automatski sakrij + W Š + H V + X X + Y Y + Frame Sync Sinhronizacija kadrova + Quick Sync Brza sinhronizacija + Audio Zvuk + Frame Duration Trajanje kadra + Loop Petlja + Split at Playhead Podeli na mestu glave za reprodukciju + Flatten Frames Into Clips Spljošti kadrove u klipove + Flatten Frames Into Layers Spljošti kadrove u slojeve + Convert Frames > Video Layers Pretvori kadrove > video slojevi + New Video Layer Novi video sloj + New Video Group Nova video grupa + Limit Playback Ograniči reprodukciju + Enable Timeline Quick Config Omogući brzo podešavanje vremenske linije + Set Start Timecode… Postavi početni vremenski kod… + Timecode Vremenski kod + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Trenutno vreme + Out Izlaz + In Ulaz + Duration Trajanje + Repeat Ponavljaj + Play Once Pusti jednom + Info Options… Opcije informacija… + First Color Readout Prvo očitavanje boje + Second Color Readout Drugo očitavanje boje + Actual Color Stvarna boja + Grayscale Slider Klizač nijansi sive + Web Color Veb boja + HSB Color HSB boja + Total Ink Ukupno mastilo + Opacity Mask Maska neprozirnosti + Ruler Lenjir + Document Size Veličina dokumenta + Timing Vreme izvršavanja + Status Information Statusne informacije + Saved Multichannel Sačuvano višekanalno + Mouse Coordinates Koordinate miša + Ruler Units Jedinice lenjira + Hide Sakrij + New Layer Comp Nova kompozicija slojeva + Duplicate Layer Comp Dupliraj kompoziciju slojeva + Rename Layer Comp Preimenuj kompoziciju slojeva + Apply Layer Comp Primeni kompoziciju slojeva + Update Layer Comp Ažuriraj kompoziciju slojeva + Restore last viewed comp Vrati poslednju prikazanu kompoziciju + Method Metod + Select All Layers Izaberi sve slojeve + Keep proportions Zadrži proporcije + Interpolation Interpolacija + Author Autor + Caption Natpis + Caption Credits Zasluge za natpis + Keywords Ključne reči + Copyright Info URL URL informacija o autorskim pravima + Copyright Status Status autorskih prava + Copyrighted Zaštićeno autorskim pravima + Public Domain Javno dobro + Unknown Nepoznato + Credit Zasluge + Transmission Reference Referenca prenosa + Urgency Hitnost + High Visoko + Low Nisko + Category Kategorija + Supplemental Categories Dodatne kategorije + Object Name / Title Ime objekta / naslov + Date Created Datum nastanka + City Grad + Location Lokacija + Province / State Pokrajina / država + Country Zemlja + Byline Potpis autora + Byline Title Zvanje autora + Phone Telefon + Email E-pošta + URLs URL adrese + Last Modified Poslednja izmena + EXIF EXIF + GPS GPS + Camera Data Podaci fotoaparata + Camera Model Model fotoaparata + Camera Make Proizvođač fotoaparata + EXIF Version EXIF verzija + Exposure Program Program ekspozicije + Aperture Otvor blende + Shutter Speed Brzina zatvarača + Metering Mode Režim merenja svetla + Light Source Izvor svetla + Flash Blic + Lens Info Podaci o objektivu + ISO Speed ISO osetljivost + Subject Distance Udaljenost objekta + DateTime Original Izvorni datum i vreme + Create Date Datum kreiranja + Modify Date Datum izmene + Software Softver + Raw Data Neobrađeni podaci + Advanced Napredno + Import… Uvezi… + PDF Presentation… PDF prezentacija… + Create Multiple Picture Packages Napravi više paketa slika + Picture Package… Paket slika… + Web Photo Gallery… Veb galerija fotografija… + Choose… Izaberi… + Select Folder… Izaberi fasciklu… + Override Action "Save" Commands Nadjačaj komande „Sačuvaj“ u radnji + File Naming Imenovanje datoteka + Compatibility Kompatibilnost + Mac OS Mac OS + Unix Unix + Include ICC Profile Uključi ICC profil + Error File Datoteka grešaka + Stop For Errors Zaustavi se kod grešaka + Log Errors To File Beleži greške u datoteku + Errors Greške + Settings Podešavanja + Settings: Podešavanja: + Working Space Radni prostor boja + RGB: RGB: + CMYK: CMYK: + Gray: Siva: + Spot: Spot: + Advanced Controls Napredne kontrole + Desaturate Monitor Colors By Smanji zasićenost boja monitora za + Blend RGB Colors Using Gamma 1.0 Stapaj RGB boje pomoću game 1.0 + Profile Mismatches: Ask When Opening Neslaganje profila: pitaj pri otvaranju + Ask When Pasting Pitaj pri lepljenju + Missing Profiles: Ask When Opening Profili koji nedostaju: pitaj pri otvaranju + Embed profiles Ugradi profile + Ask Before Saving Saved Embedded Pitaj pre čuvanja sa ugrađenim profilom + Coerce to sRGB Prinudno pretvori u 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 Wide Gamut RGB + 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 + Graphic and web Grafika i veb + Minimalistic Minimalistički + Expand All Groups Proširi sve grupe + Photo (2 × 3 in) Fotografija (2 × 3 in) + Photo (4 × 6 in) Fotografija (4 × 6 in) + Photo (5 × 7 in) Fotografija (5 × 7 in) + Photo (8 × 10 in) Fotografija (8 × 10 in) + Recent Items Nedavne stavke + Save Preset… Sačuvaj šablon… + Delete Preset… Obriši šablon… + Preset Name: Ime šablona: + Width and Height Širina i visina + Color Profile Profil boja + Units Jedinice + Columns Kolone + Create new count group Napravi novu grupu brojanja + Adjustment Layer Korekcioni sloj + Pencil Tool Alatka olovka + Pixel Piksel + Adjustment Korekcija + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Prevucite (laso) ili kliknite tačke (mnogougao) · {add} dodaje · {sub} oduzima + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · strelice pomeraju malo · X menja orijentaciju · {commit} potvrđuje · Esc otkazuje + {mode} Color · {bits} bit {mode} boje · {bits} bita + Drag to move the layer Prevucite da pomerite sloj + Scrubby Zoom Zumiranje prevlačenjem + Auto Erase Automatsko brisanje + Color Adaptation Prilagođavanje boja + Preserve Transparency Očuvaj providnost + shortcut prečica + Go to Layer Idi na sloj + Saved Sačuvano + After (X) Posle (X) + Current Tekuće + Reference point location Položaj referentne tačke + Toggle reference point Uključi/isključi referentnu tačku + Arc Luk + Arch Svod + Bulge Ispupčenje + Shell Lower Školjka dole + Shell Upper Školjka gore + Flag Zastava + Fish Riba + Wave Talas + Rise Uzdizanje + Inflate Naduvavanje + Twist Zavrtanje + Squeeze Stiskanje + Add Pins Dodaj čiode + Delete Pins Obriši čiode + Rotate Auto Automatska rotacija + Rotate Fixed Fiksna rotacija + Create Plane Napravi ravan + Edit Plane Uredi ravan + Select Plane Izaberi ravan + Measure Meri + Heal Popravka + Heal Off Popravka isključena + Heal On Popravka uključena + Heal Auto Automatska popravka + Blend Stapanje + Brush Size Veličina četkice + Heal Color Popravka boje + Mask Maska + Channel Kanal + Rectangle Pravougaonik + Result Rezultat + New Channel Novi kanal + Save Selection Sačuvaj selekciju + Load Selection Učitaj selekciju + Duplicate Dupliraj + Fast Brzo + Accurate Precizno + Add to Dodaj u + Subtract from Oduzmi od + Intersect with Presek sa + Capture Comp Zabeleži kompoziciju + No Layer Comps Nema kompozicija slojeva + Adobe PDF Presets… Adobe PDF šabloni… + 3D… 3D… + Technology Previews… Probne tehnologije… + Background from Layer Pozadina od sloja + Add a mask (from the selection; ⌥ inverts) Dodaj masku (iz selekcije; ⌥ invertuje) + Add to selection (⇧) Dodaj u selekciju (⇧) + Airbrush: keeps painting while the pointer rests. Vazdušna četkica: nastavlja da slika dok pokazivač miruje. + Click the first point or press ↵ to close · Esc cancels Kliknite prvu tačku ili pritisnite ↵ da zatvorite · Esc otkazuje + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Kliknite da dodate tačku · prevucite van, ⌘/Ctrl-klik ili Delete da uklonite + Click to apply, ⌥-click to clear overrides, double-click for Style Options Kliknite da primenite, ⌥-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to zoom in · ⌥-click to zoom out Kliknite da uvećate · ⌥-klik da umanjite + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · ↩ završi · Esc otkaži + Commit Perspective Warp (↩) Potvrdi perspektivno krivljenje (↩) + Commit Puppet Warp (↩) Potvrdi lutkarsko krivljenje (↩) + Commit any current edits (⌘↩) Potvrdi sve tekuće izmene (⌘↩) + Commit current crop operation (↵) Potvrdi tekuće opsecanje (↵) + Commit transform (↩) Potvrdi transformaciju (↩) + Commit warp (↩) Potvrdi krivljenje (↩) + Create a new layer (⇧⌘N) Napravi novi sloj (⇧⌘N) + Custom pattern Prilagođena šara + General Brushes Opšte četkice + Grid (⌘') Mreža (⌘') + Intersect with selection (⇧⌥) Presek sa selekcijom (⇧⌥) + New document… ⌘N Novi dokument… ⌘N + Open… ⌘O Otvori… ⌘O + Rulers (⌘R) Lenjiri (⌘R) + Search commands (⌘K) Pretraži komande (⌘K) + Show Guides (⌘;) Prikaži vodilje (⌘;) + Subtract from selection (⌥) Oduzmi od selekcije (⌥) + Uses the foreground and background colors. Koristi boje prednjeg plana i pozadine. + ↑↓ navigate ↵ run esc close ↑↓ kretanje ↵ pokreni esc zatvori + Adds grain to the soft edges of the brush tip. Dodaje zrno mekim ivicama vrha četkice. + Keeps the current texture when switching presets. Zadržava tekuću teksturu pri promeni šablona. + Watercolour look: lighter interior, darker rims. Izgled akvarela: svetlija unutrašnjost, tamniji rubovi. + Collapse group (⌥-click: all groups) Skupi grupu (⌥-klik: sve grupe) + Expand group (⌥-click: all groups) Proširi grupu (⌥-klik: sve grupe) + Hide the layer's effects (⌥-click: all layers) Sakrij efekte sloja (⌥-klik: svi slojevi) + Show the layer's effects (⌥-click: all layers) Prikaži efekte sloja (⌥-klik: svi slojevi) + +# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). + New from Clipboard Novo iz ostave + Free Transform a Copy Slobodna transformacija kopije + Transform Again on a Copy Ponovi transformaciju na kopiji + Select Layer Above Izaberi sloj iznad + Select Layer Below Izaberi sloj ispod + Select Top Layer Izaberi gornji sloj + Select Bottom Layer Izaberi donji sloj + Add Layer Above to Selection Dodaj sloj iznad u izbor + Add Layer Below to Selection Dodaj sloj ispod u izbor + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) + 8 bit 8 bita + Integer Celobrojno + 16 bit 16 bita + 32 bit (float) 32 bita (pokretni zarez) + Floating point Pokretni zarez + Use Global Light Koristi globalno svetlo + Native file drag-and-drop is unavailable Izvorno prevlačenje i otpuštanje datoteka nije dostupno + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili kopirajte sliku u menadžeru datoteka i nalepite je pomoću {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Da biste otpuštali datoteke, pokrenite PhotoCraft pod XWayland-om: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ili postavite Podešavanja › Performanse › Linux server prikaza na X11: PhotoCraft će se tada uvek pokretati pod XWayland-om. + Edit › Paste Uređivanje › Nalepi + Use File › Open, or paste a copied image with {paste}. Koristite Datoteka › Otvori ili nalepite kopiranu sliku pomoću {paste}. + Mixer Brush Tool Alatka četkica za mešanje + Color Replacement Tool Alatka za zamenu boje + Invalid {mode} profile Neispravan {mode} profil + Unnamed {mode} profile Neimenovani {mode} profil + Show bounding box when dragging layer Prikaži granični okvir pri prevlačenju sloja + System title bar Sistemska naslovna traka + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso za zamrzavanje: prevucite da zamrznete oblast, Alt-prevlačenje da je odmrznete (L) + Rendering Mode Režim renderovanja + Automatic (recommended) Automatski (preporučeno) + CPU / Compatibility CPU / kompatibilnost + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatski režim koristi GPU ubrzanje kada je dostupno, a u slučaju grešaka prelazi na renderovanje procesorom. + Switched to CPU rendering Prebačeno na renderovanje procesorom + GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo da se nastavi. Vaši dokumenti su nepromenjeni. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slike. Prozor i dalje može da koristi vaš grafički adapter. + Retry GPU requires a restart. Save your work first. Ponovni pokušaj sa GPU-om zahteva ponovno pokretanje. Prvo sačuvajte svoj rad. + Keep Using CPU Nastavi sa CPU-om + Retry GPU Ponovo pokušaj sa GPU-om + GPU Backend GPU pozadinski sistem + Details Detalji + Rendering mode Režim renderovanja + +# Camera Raw scopes and the Patch tool + Shadow Clipping Warning (U) Upozorenje o odsecanju senki (U) + Highlight Clipping Warning (O) Upozorenje o odsecanju svetla (O) + Show Lab Color Readouts Prikaži Lab očitavanja boja + Show Vectorscope Prikaži vektorskop + Show Red at 3 o'clock Prikaži crvenu na poziciji 3 sata + Show Skin Tone Indicator Prikaži indikator tona kože + Tone Histogram Tonski histogram + Show Selected Region Prikaži izabranu oblast +cameraRaw Light Svetlo + Camera Raw Filter ({layer}) Camera Raw filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights Svetli tonovi +cameraRaw Darks Tamni tonovi +cameraRaw Blacks Crne +cameraRaw Amount Količina +cameraRaw Tint Preliv +cameraRaw Masking Maskiranje +cameraRaw Blending Stapanje + Patch Tool Alatka zakrpa + Patch: Zakrpa: + Lasso around an area, then drag the selection Zaokružite oblast lasom, a zatim prevucite selekciju + Transform On Drop Transformiši pri otpuštanju + Scale or rotate the moved area, then press {key} Skalirajte ili rotirajte pomerenu oblast, a zatim pritisnite {key} + Now drag the selection onto another area Sada prevucite selekciju na drugu oblast + +# Warp grid presets (#388) + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Mreža: + Edit Type Uredi tekst + + New Style… Novi stil… + Style {name} saved Stil {name} je sačuvan + + Create new style (from the selected type) Napravi novi stil (od izabranog teksta) + Create new style from the selected layer Napravi novi stil od izabranog sloja + + Make Default Postavi kao podrazumevano + Reset to Default Vrati na podrazumevano + Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan + No patterns Nema šara + Enable {name} Omogući {name} + Hold Alt while drawing for straight segments Držite Alt tokom crtanja za prave segmente + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Prevucite ili kliknite tačke mnogougla · držite Alt tokom crtanja lasom za prave segmente · {add} dodaje · {sub} pre crtanja oduzima + Extend Proširi + Structure: Struktura: + Content-Aware Move Tool Alatka za pomeranje prema sadržaju + TIFF Options TIFF opcije + Targa Options Targa opcije + 24 bits/pixel 24 bita/piksel + 32 bits/pixel 32 bita/piksel + Including layers will increase file size. Uključivanje slojeva povećaće veličinu datoteke. + Discard Layers and Save a Copy Odbaci slojeve i sačuvaj kopiju + Don't show again Ne prikazuj ponovo + Save failed: {error} Čuvanje nije uspelo: {error} + Save cancelled Čuvanje je otkazano + Couldn't place {name}: {error} Postavljanje {name} nije uspelo: {error} + Magnetic Lasso Tool Alatka magnetni laso + Frequency Frekvencija + Use tablet pressure to change pen width Koristi pritisak na tabletu za promenu širine pera + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About O programu + Contributors Saradnici + Models Modeli + Sort Sortiraj + Username Korisničko ime + Display name Prikazno ime + Real name Pravo ime + Reverse the order Obrni redosled + Grab bag Mešavina + Table Tabela + Name (A–Z) Ime (A–Z) + Merged PRs Spojeni PR-ovi + Commits Komitovi + Lines added Dodate linije + Lines deleted Obrisane linije + Line delta Razlika u linijama + Binary assets added Dodati binarni resursi + Binary assets removed Uklonjeni binarni resursi + First commit Prvi komit + First Prvi + Last commit Poslednji komit + Last Poslednji + Contributors: {contributors} · Commits: {commits} Saradnici: {contributors} · Komitovi: {commits} + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o saradnicima. + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge modela. + Company Kompanija + Version Verzija + % of all commits % svih komitova + Lines +/− Linije +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR-ova: {prs}, komitova: {commits}, linija: +{added} / −{deleted} (Δ {delta}), binarnih resursa: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch Napravi novi uzorak boje + New Swatch… Novi uzorak boje… + New Swatch Group… Nova grupa uzoraka boja… + Import Swatches… Uvezi uzorke boja… + Replace Swatches… Zameni uzorke boja… + Export Swatches… Izvezi uzorke boja… + Export Swatches for Exchange… Izvezi uzorke boja za razmenu… + Reset Swatches Resetuj uzorke boja + Add to Swatches Dodaj u uzorke boja +# Layer Style > Blending Options (Advanced Blending) + Blend If: Stapaj ako: + Channels: Kanali: + Knockout Izbijanje + Apply At Canvas Bounds Primeni na granicama platna + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Birač boja (boja teksta) + Color Picker ({name}) Birač boja ({name}) + Color Picker (Fill Color) Birač boja (boja popune) + Fill color Boja popune + Color Picker (Stroke Color) Birač boja (boja obruba) + Stroke color Boja obruba + Pick screen color Uzmi boju sa ekrana + Sample a pixel anywhere on the screen Uzorkujte piksel bilo gde na ekranu + Click to pick · Esc to cancel Kliknite da uzmete boju · Esc za otkazivanje + Pick a screen pixel or press Esc to cancel Izaberite piksel na ekranu ili pritisnite Esc da otkažete + Scroll to zoom Pomerajte točkić za zumiranje + Show/Hide All Panels Prikaži/sakrij sve panele + Show/Hide Panels Prikaži/sakrij panele + Could not open Otvaranje nije uspelo + {path}: the path is not valid Unicode; rename the file and open it again. {path}: putanja nije ispravan Unicode; preimenujte datoteku i ponovo je otvorite. + +# Fill dialog, layer style contours and Image Size + Contents: Sadržaj: + Custom Pattern: Prilagođena šara: + Foreground Color Boja prednjeg plana + Color… Boja… + 50% Gray 50% siva + {size} (was {old}) {size} (bilo je {old}) + Cone Kupa + Cone (Inverted) Kupa (obrnuta) + Domed Kupola + Domed (Inverted) Kupola (obrnuta) + Diagonal (Descending) Dijagonala (opadajuća) + Contour Editor Uređivač konture + Anti-aliased Uglađeno + Corner Ugao + Gloss Contour Kontura sjaja + Contour Kontura + Cone - Inverted Kupa - obrnuta + Cove - Deep Udubljenje - duboko + Cove - Shallow Udubljenje - plitko + Gaussian Gausova + Half Round Polukrug + Ring Prsten + Ring - Double Prsten - dvostruki + Rolling Slope - Descending Talasasta kosina - opadajuća + Rounded Steps Zaobljeni stepenici + Sawtooth 1 Testera 1 + + Red Eye Tool Alatka za crvene oči + Pupil Size Veličina zenice + Darken Amount Količina zatamnjenja + Click a red pupil to neutralize it Kliknite crvenu zenicu da je neutrališete + open source otvoreni kod + …and {n} more …i još {n} + + Eyedroppers: Pipete: + Set Black Point Postavi crnu tačku + Set Neutral Gray Point Postavi tačku neutralne sive + Set White Point Postavi belu tačku + Unavailable in this color mode Nedostupno u ovom režimu boja + Selection Preview: Pregled selekcije: + Black Matte Crna podloga + White Matte Bela podloga + +# Layer colour labels. + Seafoam Morska pena + Indigo Indigo + Fuchsia Fuksija + Violet Ljubičasta + Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom + This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument. + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara. + The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason} +fontWeight Thin Tanko +fontWeight ExtraLight Ekstra lagano +fontWeight Light Lagano +fontWeight Regular Obično +fontWeight Medium Srednje +fontWeight SemiBold Polupodebljano +fontWeight Bold Podebljano +fontWeight ExtraBold Ekstra podebljano +fontWeight Black Crno +fontWeight Italic Kurziv + +# Pattern Stamp tool + Pattern Stamp Tool Alatka pečat za šaru + Impressionist Impresionistički + Rotate View Tool Alatka za rotiranje prikaza + Reset View Resetuj prikaz + Drag around the centre to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° + +# Painting symmetry presets. + Dual Axis Dvostruka osa + Diagonal Dijagonala + Transform Symmetry Transformiši simetriju + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Uzorak tačke + 3 by 3 Average Prosek 3 x 3 + 5 by 5 Average Prosek 5 x 5 + 11 by 11 Average Prosek 11 x 11 + 31 by 31 Average Prosek 31 x 31 + 51 by 51 Average Prosek 51 x 51 + 101 by 101 Average Prosek 101 x 101 + Sample Size: Veličina uzorka: + All Layers No Adjustments Svi slojevi bez korekcija + Current & Below No Adjustments Tekući i ispod bez korekcija + Show Sampling Ring Prikaži prsten uzorkovanja + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa + Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju + Color to Alpha… Boja u alfu… + Transparency Threshold Prag providnosti + Opacity Threshold Prag neprozirnosti + PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana + + Rasterize svg groups deeper than Rasterizuj SVG grupe dublje od + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 isključuje ovaj prag; SVG grupe ugnežđene dublje od 100 nivoa uvek se rasterizuju. + Rule of Thirds Pravilo trećina + Triangle Trougao + Golden Ratio Zlatni presek + Golden Spiral Zlatna spirala + Auto Show Overlay Automatski prikaži preklop + Always Show Overlay Uvek prikaži preklop + Never Show Overlay Nikad ne prikazuj preklop + Cycle Overlay Smenjuj preklope + Cycle Orientation Smenjuj orijentaciju + Set the overlay options for the Crop tool Podesi opcije preklopa za alatku za opsecanje + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik je preuzelo radno okruženje, koje pomera prozore Alt-prevlačenjem. Postavite taster za pomeranje prozora u njemu na Super ili zaključajte Alt u Prozor › Modifikatorski tasteri. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Birač boja (boja stila sloja) + Auto Hide Notifications Automatski sakrij obaveštenja + Notification Duration (seconds) Trajanje obaveštenja (sekunde) + Appearance Mode Režim izgleda + Dark Theme Tamna tema + Light Theme Svetla tema + Pro Medium Pro srednja + Studio Light Studio svetla + Next Appearance Mode Sledeći režim izgleda + Sync with system Usklađeno sa sistemom + Active Aktivno + Color Picker (Solid Color) Birač boja (puna boja) + Straighten the image by drawing a line on it Ispravite sliku tako što ćete povući liniju preko nje + Set additional Crop options Podesi dodatne opcije opsecanja + Show Cropped Area Prikaži isečenu oblast + Enable Crop Shield Omogući zaštitni sloj opsecanja + Match Canvas Uskladi sa platnom + Auto Adjust Opacity Automatski podesi neprozirnost + Crop Shield color Boja zaštitnog sloja opsecanja + Color Picker (Crop Shield Color) Birač boja (boja zaštitnog sloja opsecanja) + From Center Od centra + Link radii Poveži poluprečnike + Star Ratio: Odnos zvezde: + Create Rectangle Napravi pravougaonik + Create Ellipse Napravi elipsu + Create Triangle Napravi trougao + Create Polygon Napravi mnogougao + Pressure curve Kriva pritiska + Click to add a point, right-click to remove Kliknite da dodate tačku, desni klik da je uklonite + Some brush presets could not be loaded Neki šabloni četkica nisu mogli da se učitaju + Retry saving presets Ponovo pokušaj čuvanje šablona + Brush preset changes are not saved: {error} Izmene šablona četkica nisu sačuvane: {error} + Keep this tab open to retain your brushes. Držite ovu karticu otvorenom da biste zadržali svoje četkice. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Skladište pregledača nije dostupno; četkice važe samo za ovu sesiju. Držite ovu karticu otvorenom da biste ih zadržali. + Saving brush presets… Keep this tab open until saving finishes. Čuvanje šablona četkica… Držite ovu karticu otvorenom dok se čuvanje ne završi. + Reflect Refleksija + Glow Sjaj + Negation Negacija + XOR XOR + Color Burn (Paint.NET) Zatamnjivanje boje (Paint.NET) + Color Dodge (Paint.NET) Posvetljivanje boje (Paint.NET) + Stroke Options Opcije obruba + Solid Puna + Dashed Isprekidana + Dotted Tačkasta + Caps Završeci + Butt / Flat Ravno / tupo + Corners Uglovi + Miter Šiljato + Round Zaobljeno + Bevel Zakošeno + Miter limit Granica šiljatosti + Dashed line Isprekidana linija + Dash Crtica + Gap Razmak + Dash / gap lengths × stroke width Dužine crtica / razmaka × širina obruba + Add pair Dodaj par + Remove pair Ukloni par + Dash offset Pomak crtica + Open paths use centered strokes Otvorene putanje koriste centrirani obrub + Use finite lengths and a positive pattern total Koristite konačne dužine i pozitivan ukupni zbir šablona + No subject found — try Object Selection or the Lasso Subjekat nije pronađen — probajte selekciju objekta ili laso + Quick Selection found nothing there Brza selekcija tu nije ništa pronašla + Object Selection found nothing there — try a tighter rectangle Selekcija objekta tu nije ništa pronašla — probajte uži pravougaonik + No subject found — try a rectangle around the object Subjekat nije pronađen — probajte pravougaonik oko objekta + Click the first point or press {key} to close · {del} removes a point · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · {del} uklanja tačku · Esc otkazuje + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kliknite, pa se pomerajte duž ivice · kliknite da pričvrstite tačku · {alt}-klik za pravi segment · dvoklik za zatvaranje + Follows only edges this close to the pointer ([ and ] change it) Prati samo ivice na ovoj udaljenosti od pokazivača ([ i ] je menjaju) + Higher values follow only edges that contrast sharply with their surroundings Veće vrednosti prate samo ivice koje su u oštrom kontrastu sa okolinom + How often fastening points are placed by themselves Koliko se često tačke pričvršćivanja postavljaju same + Appearance Settings… Podešavanja izgleda… + Add to favorites Dodaj u omiljene + Remove from favorites Ukloni iz omiljenih +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Proizvođač +cameraData Model Model +cameraData Date Taken Datum snimanja +cameraData Exposure Ekspozicija +cameraData F-Stop F-broj +cameraData ISO Speed ISO osetljivost +cameraData Focal Length Žižna daljina +cameraData Focal Length (35 mm) Žižna daljina (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktiviraj panel + Change Panel Group Promeni grupu panela + Change Panel Layout Promeni raspored panela + Close Panel Zatvori panel + Dock Panel Usidri panel + Float Panel Odvoji panel + Move Panel Premesti panel + Group with Grupiši sa + Panel menu Meni panela + Resize panel window Promeni veličinu prozora panela + Resize panels Promeni veličinu panela + Use Classic Mode Koristi klasični režim + Auto Center Preview Automatski centriraj pregled + Next Document Sledeći dokument + Previous Document Prethodni dokument + W x H x Resolution Š x V x rezolucija + Front Image Prednja slika + Frame Tool Alatka okvir + 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 + 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 + Artboard Tool Alatka radna tabla + Make Portrait Postavi uspravno + Make Landscape Postavi položeno + Add New Artboard Dodaj novu radnu tablu + Freeform Pen Tool Alatka slobodno pero + Curvature Pen Tool Alatka pero za krive + Curve Fit: Prilagođavanje krive: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · {alt}-klik: pravi segment · Otpuštanje na početnoj tački: zatvori + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glatka tačka · Dvoklik: ugao · Prevlačenje tačke: pomeri · Klik na prvu tačku: zatvori · {key} završi + New Crop Preset… Novi šablon opsecanja… + Delete Crop Preset… Obriši šablon opsecanja… + Select the crop presets to delete: Izaberite šablone opsecanja za brisanje: + Select the crop presets to delete Izaberite šablone opsecanja za brisanje + Enter a name for the crop preset Unesite ime šablona opsecanja + There is no crop size or ratio to save Nema veličine ili odnosa opsecanja za čuvanje + Scale: Razmera: + Snap to Origin Prianjaj uz ishodište + Search settings… Pretraži podešavanja… + No matching settings Nema odgovarajućih podešavanja + Loading fonts… Učitavanje fontova… + Tool Alatka + Simulate Pressure Simuliraj pritisak + Stroke Path… Ocrtaj putanju… + Background Eraser Gumica za pozadinu + Healing Brush Četkica za popravku + Spot Healing Brush Četkica za tačkastu popravku + Smudge Razmazivanje + Sponge Sunđer + Amount: Količina: + Protect: Zaštiti: + Protect skin tones Zaštiti tonove kože + Edit points to modify the curve Uredite tačke da izmenite krivu + Draw to modify the curve Crtajte da izmenite krivu +curves Smooth Izgladi + Smooth the curve values Izgladi vrednosti krive + Drag to draw the curve · {key}-click draws a straight line Prevucite da nacrtate krivu · {key}-klik crta pravu liniju + Grid Size: Veličina mreže: + Vanishing Point: click four corners to create a plane Tačka nestajanja: kliknite četiri ugla da napravite ravan + Vanishing Point is not active Tačka nestajanja nije aktivna + Apply Vanishing Point ({key}) Primeni tačku nestajanja ({key}) + Fix the red plane first: its corners must form a convex quad Prvo ispravite crvenu ravan: njeni uglovi moraju činiti konveksan četvorougao +# Select and Mask view modes (#1754) + Onion Skin Prozirni prikaz + Marching Ants Pokretne crtice + On Black Na crnoj + On White Na beloj + On Layers Na slojevima + Show Edge Prikaži ivicu + Show Original Prikaži original + F cycles the views; hold X to see the image F smenjuje prikaze; držite X da vidite sliku +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Četkica za doterivanje ivice (R) + Expand Detection Area Proširi oblast detekcije + Restore Original Edge Vrati originalnu ivicu + Paint over hair or fur on the image; Alt paints the other mode Slikajte preko kose ili krzna na slici; Alt slika u drugom režimu + Clear Brush Strokes Obriši poteze četkicom + Show rulers Prikaži lenjire + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Kliknite da postavite · Alt-klik ili prevlačenje van platna da uklonite + No document open Nijedan dokument nije otvoren + {n} of {max} sample points {n} od {max} tačaka uzorkovanja +# Print dialog (#2697) + Emulsion Down Emulzija nadole + Functions Funkcije + Print Resolution: Rezolucija štampe: + Use Previous Layer to Create Clipping Mask Koristi prethodni sloj za pravljenje maske isecanja + Fill with {mode}-neutral color (white) Popuni neutralnom bojom za {mode} (bela) + Fill with {mode}-neutral color (black) Popuni neutralnom bojom za {mode} (crna) + Fill with {mode}-neutral color (50% gray) Popuni neutralnom bojom za {mode} (50% siva) + (No neutral color exists for {mode} mode.) (Za režim {mode} ne postoji neutralna boja.) + Apply mask to layer before removing? Primeniti masku na sloj pre uklanjanja? + The layer or its mask changed. Drag the mask onto the Trash again. Sloj ili njegova maska su se promenili. Ponovo prevucite masku u kantu. + Delete the vector mask? Obrisati vektorsku masku? +# View › Guides › New Guide… › Color + Light Blue Svetloplava + Light Red Svetlocrvena + Medium Blue Srednje plava + Light Gray Svetlosiva + Remove after each stroke Ukloni posle svakog poteza + Apply the removal ({key}) Primeni uklanjanje ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Potezi za uklanjanje: {n} (Enter primenjuje, Esc otkazuje) + +# Gradient Editor window, opened from the options bar's gradient swatch. + The current gradient (pick one in Window › Gradients) Tekući gradijent (izaberite ga u Prozor › Gradijenti) + +# Camera Raw: contextual controls and window title +cameraRaw Red Crvena +cameraRaw Orange Narandžasta +cameraRaw Yellow Žuta +cameraRaw Green Zelena +cameraRaw Aqua Akva +cameraRaw Blue Plava +cameraRaw Purple Ljubičasta +cameraRaw Magenta Magenta +cameraRaw Temperature Temperatura + +# Counted status messages and labels (Serbian: one | few | many) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Izvezen je {n} uzorak boje u {file}|Izvezena su {n} uzorka boja u {file}|Izvezeno je {n} uzoraka boja u {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Uvezena je {n} četkica iz {group}|Uvezene su {n} četkice iz {group}|Uvezeno je {n} četkica iz {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Uvezen je {n} uzorak boje iz {file}|Uvezena su {n} uzorka boja iz {file}|Uvezeno je {n} uzoraka boja iz {file} +@plural Played {n} step|Played {n} steps Izvršen je {n} korak|Izvršena su {n} koraka|Izvršeno je {n} koraka +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ažuriran je {n} povezani pametni objekat izmenjen na disku|Ažurirana su {n} povezana pametna objekta izmenjena na disku|Ažurirano je {n} povezanih pametnih objekata izmenjenih na disku +@plural Saved for Web: {n} file|Saved for Web: {n} files Sačuvano za veb: {n} datoteka|Sačuvano za veb: {n} datoteke|Sačuvano za veb: {n} datoteka +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} sloj zabeležen u ovoj kompoziciji je obrisan. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} sloja zabeležena u ovoj kompoziciji su obrisana. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} slojeva zabeleženih u ovoj kompoziciji je obrisano. Ažurirajte kompoziciju ili uklonite upozorenje. +@plural {n} plane|{n} planes {n} ravan|{n} ravni|{n} ravni +@plural {n} preset|{n} presets {n} šablon|{n} šablona|{n} šablona +@plural {n} sample point removed|{n} sample points removed Uklonjena je {n} tačka uzorkovanja|Uklonjene su {n} tačke uzorkovanja|Uklonjeno je {n} tačaka uzorkovanja +@plural {n} slice|{n} slices {n} isečak|{n} isečka|{n} isečaka +@plural {n} step|{n} steps {n} korak|{n} koraka|{n} koraka +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+ još {n} upozorenje za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica) +@plural {what} with {n} warning|{what} with {n} warnings {what} sa {n} upozorenjem|{what} sa {n} upozorenja|{what} sa {n} upozorenja + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) +@id edit.stroke Ocrtaj… +@id edit.transform.scale Skaliranje +cameraRaw Luminance Luminansa +cameraRaw Color Boja +cameraRaw Vibrance Živost +cameraRaw Color Grading Gradacija boja +cameraRaw Global Globalno +cameraRaw Hue Nijansa +cameraRaw Luminance Detail Detalji luminanse +cameraRaw Color Detail Detalji boje +cameraRaw Roughness Hrapavost +cameraRaw Whites Bele +layerLabel No Color Bez boje + Preferences… Podešavanja… + Actual Pixels Stvarni pikseli + Clipping Mask Maska isecanja + Tool Options Opcije alatke + Color Picker Birač boja + Color Swatches Uzorci boja + Linear Dodge Linearno posvetljivanje + Preview Image Pregled slike + Save As Sačuvaj kao + Remove All Ukloni sve + Unlock Otključaj + Enable Omogući + Disable Onemogući + On Uključeno + Clear Selection Ukloni selekciju + Invert Selection Invertuj selekciju + Select Inverse Izaberi inverzno + Contract Suzi + Sample Size Veličina uzorka + Strength Jačina + Gamma Gama + Input Ulaz + Input Levels Ulazni nivoi + Output Levels Izlazni nivoi + Layer Styles Stilovi slojeva + Blending Mode Režim stapanja + Fill Color Boja popune + Stroke Color Boja obruba + Stroke Width Širina obruba + Shadow Senka + Adjustment Layers Korekcioni slojevi + Text Layer Tekstualni sloj + Documents Dokumenti + File Name Ime datoteke + File Format Format datoteke + Compression Kompresija + Artboards Radne table + Snap to Grid Prianjaj uz mrežu + Full Screen Ceo ekran + Fullscreen Ceo ekran + Workspaces Radni prostori + Show All Prikaži sve + Hidden Skriveno + Duplicate Layer Dupliraj sloj + Ungroup Razgrupiši + Lock Transparent Pixels Zaključaj providne piksele + Lock Image Pixels Zaključaj piksele slike + Lock Position Zaključaj položaj + Lock All Zaključaj sve + Show Rulers Prikaži lenjire + Guide Layout… Raspored vodilja… + Page Setup… Podešavanje stranice… + Reset Preferences Resetuj podešavanja + Documentation Dokumentacija + Check for Updates Proveri ažuriranja + Loading… Učitavanje… + Processing… Obrađivanje… + Saving… Čuvanje… + Exporting… Izvoz u toku… + Please wait… Sačekajte… + No selection Nema selekcije + No layers Nema slojeva + No recent files Nema nedavnih datoteka + Unsupported file format Nepodržan format datoteke + Failed to open file Otvaranje datoteke nije uspelo + Failed to save file Čuvanje datoteke nije uspelo + Operation canceled Operacija je otkazana + Changes have not been saved. Izmene nisu sačuvane. + Are you sure? Da li ste sigurni? + Discard Changes Odbaci izmene + Recent Files Nedavne datoteke + Open File Otvori datoteku + Save File Sačuvaj datoteku + Save Changes Sačuvaj izmene + Rendering… Renderovanje… + Importing… Uvoz u toku… + Ready Spremno + Failed Neuspešno + Warning Upozorenje + Information Informacija + Retry Pokušaj ponovo + Continue Nastavi + Skip Preskoči + Resume Nastavi + Reset All Resetuj sve + Defaults Podrazumevano + Apply to All Primeni na sve + Confirm Potvrdi + Finish Završi + Back Nazad + Next Sledeće + Previous Prethodno + More Više + Less Manje + Manual Ručno + Disabled Onemogućeno + Locked Zaključano + Unlocked Otključano + Delete… Obriši… + Create… Napravi… + Edit… Uredi… + Change… Promeni… + Transformation Transformacija + Scaling Skaliranje + Canvas Size Veličina platna + Percentage Procenat + Degrees Stepeni + Middle Sredina + Diameter Prečnik + Intensity Intenzitet + Sensitivity Osetljivost + Softness Mekoća + Feather Radius Poluprečnik omekšavanja + Library Biblioteka + Layer Properties Svojstva sloja + Groups Grupe + Masks Maske + Add Mask Dodaj masku + Delete Mask Obriši masku + Invert Mask Invertuj masku + Enable Mask Omogući masku + Disable Mask Onemogući masku + Apply Mask Primeni masku + Import Settings Uvezi podešavanja + Export Settings Izvezi podešavanja + Shortcut Prečica + Shortcuts Prečice + Keyboard Tastatura + Mouse Miš + Tablet Tablet + Display Ekran + Panel Panel + Hide Grid Sakrij mrežu + Fit to Window Uklopi u prozor + Live Preview Pregled uživo + Before Pre + After Posle + Compare Uporedi + File Size Veličina datoteke + Filename Ime datoteke + Folder Fascikla + Open Folder Otvori fasciklu + Choose Folder Izaberi fasciklu + Browse Pregledaj + Find Pronađi + Replace Zameni + Date Datum + Learn More Saznajte više + Report a Bug Prijavi grešku + Updates Ažuriranja + Update Ažuriraj + Restart Required Potrebno je ponovno pokretanje + Restart Ponovo pokreni + Quit Izađi + Quit PhotoCraft Izađi iz PhotoCraft-a + Close Window Zatvori prozor + New Window Novi prozor + Open Image Otvori sliku + Save Image Sačuvaj sliku + Export Image Izvezi sliku + Import Image Uvezi sliku + Export As Izvezi kao + Export to Izvezi u + Export Options Opcije izvoza + Import Options Opcije uvoza + No Selection Nema selekcije + No Document Nema dokumenta + No Image Nema slike + No Layers Nema slojeva + No Results Nema rezultata + No Results Found Nije pronađen nijedan rezultat + Nothing to Undo Nema šta da se opozove + Nothing to Redo Nema šta da se ponovi + Changes Saved Izmene su sačuvane + Unsaved Changes Nesačuvane izmene + Save before closing? Sačuvati pre zatvaranja? + Are you sure you want to quit? Da li zaista želite da izađete? + The file could not be opened. Datoteka nije mogla da se otvori. + The file could not be saved. Datoteka nije mogla da se sačuva. + Unsupported format Nepodržan format + Out of memory Nema dovoljno memorije + Permission denied Pristup je odbijen + File not found Datoteka nije pronađena + Operation failed Operacija nije uspela + Operation completed Operacija je završena + Operation cancelled Operacija je otkazana + Sharpen Amount Količina izoštravanja + Sharpen Radius Poluprečnik izoštravanja + Sharpen Detail Detalji izoštravanja + Sharpen Masking Maskiranje izoštravanja + Noise Luminance Šum luminanse + Noise Luminance Detail Detalji šuma luminanse + Noise Color Šum boje + Noise Color Detail Detalji šuma boje + Grain Amount Količina zrna + Grain Size Veličina zrna + Grain Roughness Hrapavost zrna + Vignette Roundness Zaobljenost vinjete + Vignette Feather Omekšavanje vinjete + Vignette Highlights Svetla vinjete + Vignette Style Stil vinjete + Planes Ravni + Plane Ravan + Shadow Amount Količina senki + Shadow Tone Ton senki + Shadow Radius Poluprečnik senki + Highlight Amount Količina svetla + Highlight Tone Ton svetla + Highlight Radius Poluprečnik svetla + Midtone Srednji ton + Black Clip Odsecanje crne + White Clip Odsecanje bele + Fuzziness Rasplinutost + Source Layer Izvorni sloj + Neutralize Neutrališi + Use Selection In Source Koristi selekciju u izvoru + Use Selection In Target Koristi selekciju u cilju + Flame Type Vrsta plamena + Randomize Length Nasumična dužina + Interval Interval + Adjust Interval For Loops Prilagodi interval za petlje + Use Custom Color Koristi prilagođenu boju + Flame Lines Linije plamena + Flame Bottom Alignment Poravnanje dna plamena + Flame Style Stil plamena + Flame Shape Oblik plamena + Randomize Shapes Nasumični oblici + Frame Okvir + Arrangement Raspored + Thickness Debljina + Vine Color Boja loze + Flower Color Boja cveta + Leaf Color Boja lista + Base Tree Type Osnovna vrsta drveta + Light Direction Smer svetla + Leaves Amount Količina lišća + Leaves Size Veličina lišća + Branches Height Visina grana + Branches Thickness Debljina grana + Default Leaves Podrazumevano lišće + Leaves Color Boja lišća + Custom Branch Color Prilagođena boja grana + Branches Color Boja grana + Flat Shading Ravno senčenje + Date and Time Datum i vreme + Scale Units Jedinice razmere + Scale Factor Faktor razmere + Area Površina + Perimeter Obim + Circularity Kružnost + Gray Value (Minimum) Vrednost sive (minimum) + Gray Value (Maximum) Vrednost sive (maksimum) + Gray Value (Mean) Vrednost sive (srednja vrednost) + Gray Value (Median) Vrednost sive (medijana) + Integrated Density Integrisana gustina + Length Dužina + In Black Ulazna crna + In White Ulazna bela + Out Black Izlazna crna + Out White Izlazna bela + Tint Color Boja preliva + Stops Tačke + Center X Centar X + Center Y Centar Y + Distribution Raspodela + Monochromatic Jednobojno + Cell Size Veličina ćelije + Generators Generatori + Wavelength Min Talasna dužina, min. + Wavelength Max Talasna dužina, maks. + Amplitude Min Amplituda, min. + Amplitude Max Amplituda, maks. + Undefined Areas Nedefinisane oblasti + Preserve Očuvaj + Max Radius Maks. poluprečnik + Depth Mode Režim dubine + Solid Front Faces Pune prednje strane + Mask Incomplete Blocks Maskiraj nepotpune blokove + Stylization Stilizacija + Cleanliness Čistoća + Bristle Detail Detalji čekinja + Lighting Osvetljenje + Shine Sjaj + Max Offset Maks. pomak + Map Document Dokument mape + Map Path Putanja mape + Map Layer Sloj mape + Ridges Grebeni + Light Type Vrsta svetla + Light X Svetlo X + Light Y Svetlo Y + Light Z Svetlo Z + Target X Cilj X + Target Y Cilj Y + Hotspot Žarište + Elevation Elevacija + Ambient Ambijentalno + Warmth Toplina + Gloss Sjajnost + Metallic Metalnost + Ambience Ambijent + White Is High Bela je visoko + Reduce Color Noise Smanji šum boje + Sharpen Details Izoštri detalje + Remove Jpeg Artifact Ukloni JPEG artefakte + Blade Curvature Zakrivljenost lamela + Depth Map Mapa dubine + Focal Distance Žižna udaljenost + Focus Fokus + Transition Prelaz + Radius X Poluprečnik X + Radius Y Poluprečnik Y + Pins Čiode + Blur Angle Ugao zamućenja + Speed Brzina + Taper Sužavanje kraja + Start X Početak X + Start Y Početak Y + End X Kraj X + End Y Kraj Y + Kernel Jezgro + Input Mode Režim ulaza + Row Order Redosled redova + Eliminate Eliminiši + Create By Napravi pomoću + List Spisak + Pencil Width Širina olovke + Stroke Pressure Pritisak poteza + Paper Brightness Osvetljenost papira + Number Of Levels Broj nivoa + Edge Simplicity Jednostavnost ivica + Edge Fidelity Vernost ivica + Brush Detail Detalji četkice + Highlight Area Oblast svetla + Glow Size Veličina sjaja + Glow Brightness Osvetljenost sjaja + Brush Type Vrsta četkice + Stroke Size Veličina poteza + Stroke Detail Detalji poteza + Highlight Strength Jačina svetla + Smoothness Glatkoća + Edge Thickness Debljina ivica + Edge Intensity Intenzitet ivica + Posterization Posterizacija + Stroke Length Dužina poteza + Relief Reljef + Definition Definicija + Texture Coverage Pokrivenost teksturom + Shadow Intensity Intenzitet senki + Edge Width Širina ivica + Edge Brightness Osvetljenost ivica + Direction Balance Balans smera + Black Intensity Intenzitet crne + White Intensity Intenzitet bele + Dark Intensity Intenzitet tamnih tonova + Custom Pixel Aspect Ratio Prilagođeni odnos stranica piksela + 32-bit Preview Options Opcije 32-bitnog pregleda + New Guide Nova vodilja + Join the ArtCraft Discord Pridružite se ArtCraft Discord zajednici + Report an Issue Prijavi problem + Load Files into Stack Učitaj datoteke u stek + Statistics Statistika + Duplicate Layers Dupliraj slojeve + Edit Toolbar Uredi traku alatki + Import Brushes Uvezi četkice + Load 3D LUT Učitaj 3D LUT + New document Novi dokument + Press keys Pritisnite tastere + Rename Group Preimenuj grupu + Rename Tool Preset Preimenuj šablon alatke + Search commands, tools and panels Pretraži komande, alatke i panele + Slice Options Opcije isečka + Rename Brush Preimenuj četkicu + Opening Otvaranje + Reading Čitanje + Working Obrada + Accented Edges Naglašene ivice + Angled Strokes Kosi potezi + Anisotropic Anizotropno + Around Center Oko centra + Artistic Umetnički + Bamboo Bambus + Bas Relief Bareljef + Berries Bobice + Blast Udar + Bleach Bypass Preskočeno beljenje + Blocks Blokovi + Brick Cigla + Brush Strokes Potezi četkicom + Burlap Juta + Candle Light Svetlost sveće + Circle Krug + Clumped Zgrudvano + Coarse Dots Krupne tačke + Colored Pencil Bojica + Contrasty Kontrastno + Cool Hladno + Craquelure Krakelir + Cross Krst + Cross Process Unakrsna obrada + Crosshatch Unakrsno šrafiranje + Cutout Isečak od papira + Daisies Bele rade + Dark Rough Tamno grubo + Dark Strokes Tamni potezi + Darken Only Samo zatamni + Day For Night Dan za noć + Deep Blue Tamnoplava + Deep Emerald Tamni smaragd + Deep Red Tamnocrvena + Deep Yellow Tamnožuta + Dot Tačka + Double Line Dvostruka linija + Draft Nacrt + Dry Brush Suva četkica + Duplication Dupliranje + Edge Extension Proširenje ivica + Edge Only Samo ivice + Enlarged Uvećano + Even Fields Parni poluslikovi + Faded Film Izbledeli film + Film Grain Filmsko zrno + Fine Fino + Fine Dots Fine tačke + Flat Ravno + Fresco Freska + From Left Sleva + From Right Zdesna + Frosted Mat staklo + Generic Opšte + Glass Staklo + Glowing Edges Svetleće ivice + Grainy Dots Zrnaste tačke + Hearts Srca + Heptagon Sedmougao + Hexagon Šestougao + Horizontal Only Samo horizontalno + Infinite Beskonačno + Ink Outlines Obrisi mastilom + Ivy Bršljan + Left Diagonal Leva dijagonala + Level Based Prema nivoima + Light Rough Svetlo grubo + Lighten Only Samo posvetli + Long Lines Duge linije + Long Strokes Dugi potezi + Lower Donje + Medium Dots Srednje tačke + Medium Lines Srednje linije + Medium Strokes Srednji potezi + Mono Contrast Jednobojni kontrast + Mosaic Tiles Mozaik pločice + Multiple Flames Along Path Više plamenova duž putanje + Multiple Flames One Direction Više plamenova u jednom smeru + Multiple Flames Path Directions Više plamenova u smerovima putanje + Multiple Flames Various Length Više plamenova različite dužine + Octagon Osmougao + Odd Fields Neparni poluslikovi + One Flame Along Path Jedan plamen duž putanje + Out From Center Od centra ka spolja + Oval Oval + Overlay Edge Ivica preklopa + Palette Knife Slikarska špahtla + Parallel Paralelno + Patchwork Mozaik od krpica + Pentagon Petougao + Point Tačka + Pointed Šiljato + Pond Ripples Talasići na jezeru + Pyramids Piramide + Random Nasumično + Right Diagonal Desna dijagonala + Rope Konopac + Roses Ruže + Sandstone Peščar + Scallops Festoni + Sepia Sepija + Short Lines Kratke linije + Short Strokes Kratki potezi + Simple Jednostavno + Light Intensity Intenzitet svetla + Spray Radius Poluprečnik raspršivanja + Stroke Direction Smer poteza + Graininess Zrnastost + Glow Amount Količina sjaja + Clear Amount Količina čistoće + Distortion Izobličenje + Ripple Size Veličina talasića + Ripple Magnitude Jačina talasića + Charcoal Area Oblast uglja + Chalk Area Oblast krede + Charcoal Thickness Debljina uglja + Light Dark Balance Balans svetlog i tamnog + Foreground Level Nivo prednjeg plana + Background Level Nivo pozadine + Pattern Type Vrsta šare + Image Balance Balans slike + Darkness Tama + Fiber Length Dužina vlakana + Crack Spacing Razmak pukotina + Crack Depth Dubina pukotina + Crack Brightness Osvetljenost pukotina + Grain Type Vrsta zrna + Tile Size Veličina pločice + Grout Width Širina fuge + Lighten Grout Posvetli fugu + Square Size Veličina kvadrata + Border Thickness Debljina oboda + Correct Distortion Ispravi izobličenje + Correct Vignette Ispravi vinjetiranje + Correct C A Ispravi hromatsku aberaciju + Auto Scale Automatsko skaliranje + Red Cyan Crvena–cijan + Blue Yellow Plava–žuta + Vignette Amount Količina vinjete + Vignette Midpoint Središte vinjete + Constraints Ograničenja + Curve Highlights Kriva: svetla + Curve Lights Kriva: svetli tonovi + Curve Darks Kriva: tamni tonovi + Curve Shadows Kriva: senke + Curve Splits Kriva: podele + Point Curve Tačkasta kriva + Point Curve Red Tačkasta kriva, crvena + Point Curve Green Tačkasta kriva, zelena + Point Curve Blue Tačkasta kriva, plava + Hsl Hue HSL nijansa + Hsl Sat HSL zasićenost + Hsl Lum HSL svetlina + Grade Shadows Gradacija: senke + Grade Midtones Gradacija: srednji tonovi + Grade Highlights Gradacija: svetla + Grade Global Gradacija: globalno + Grade Blending Gradacija: stapanje + Grade Balance Gradacija: balans + Sine Sinusoida + Sketch Skica + Snowflakes Pahuljice + Sparkle Iskrenje + Spatter Prskanje + Speckle Pegavost + Spin Okretanje + Sprinkles Mrvice + Squareness Četvrtastost + Stagger Smaknuto + Stained Glass Vitraž + Star Zvezda + Stars Zvezde + Stippled Tačkasto + Stretch Rastegni + Teal Orange Tirkizno-narandžasto + Tiny Lens Sićušni objektiv + To Center Ka centru + Unaltered Neizmenjeno + Underpainting Podslika + Underwater Pod vodom + Uniform Ravnomerno + Upper Gornje + Vertical Only Samo vertikalno + Vine With Flowers Loza sa cvećem + Vine With Leaves Loza sa lišćem + Violent Silovito + Warm Toplo + Watercolor Akvarel + Waves Talasi + Wide Blurry Široko zamućeno + Wide Sharp Široko oštro + Wrap Obmotavanje + Zigzag Cik-cak + Wetness Vlažnost + AI Integrations AI integracije + Add Noise Dodaj šum + Apply Data Set Primeni skup podataka + Apply Image Primeni sliku + Arbitrary Proizvoljno + Artboard from Group Radna tabla od grupe + Artboard from Layers Radna tabla od slojeva + Artboards to Files Radne table u datoteke + Artboards to PDF Radne table u PDF + Assign Profile Dodeli profil + At Na + Auto-Align Layers Automatski poravnaj slojeve + Auto-Blend Layers Automatski stopi slojeve + Batch Grupna obrada + Border Obod + Box Blur Pravougaono zamućenje + Calculations Proračuni + Camera Raw Camera Raw + Camera Raw Filter Camera Raw filter + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Clone Kloniraj + Color Decontaminate Dekontaminacija boja + Color Halftone Polutonski raster u boji + Color Lookup Tables Tabele preslikavanja boja + Color Settings Podešavanja boja + Color Table Tabela boja + Conditional Mode Change Uslovna promena režima + Content-Aware Fill Popuna prema sadržaju + Convert to Linked Pretvori u povezani + Convert to Profile Pretvori u profil + Crystallize Kristalizacija + Data Podaci + Data Sets as Files Skupovi podataka kao datoteke + De-Interlace Uklanjanje preplitanja + Define Brush Preset Definiši šablon četkice + Define Custom Shape Definiši prilagođeni oblik + Define Pattern Definiši šaru + Defringe Ukloni rub + Diffuse Rasipanje + Diffuse Glow Difuzni sjaj + Displace Premeštanje + Do you want to save you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Dust & Scratches Prašina i ogrebotine + Export Contents Izvezi sadržaj + Export Preferences Podešavanja izvoza + Export/Import Presets Izvoz/uvoz šablona + Extrude Istiskivanje + Fibers Vlakna + Field Blur Zamućenje polja + File Info Informacije o datoteci + Filter Gallery Galerija filtera + Find and Replace Text Pronađi i zameni tekst + Fit Image Uklopi sliku + Flame Plamen + Focus Area Oblast fokusa + Gaussian Blur Gausovo zamućenje + Global Light Globalno svetlo + Group from Layers Grupa od slojeva + HDR Toning HDR toniranje + High Pass Visokopropusni filter + Highlight Svetlo + Image Processor Obrada slika + Install Plug-in Instaliraj dodatak + Interpret Footage Tumači snimak + Iris Blur Iris zamućenje + Jag Nazubljenost + Label Oznaka + Layer Comps to Files Kompozicije slojeva u datoteke + Layer Content Options Opcije sadržaja sloja + Layer From Background Sloj od pozadine + Layer from Background Sloj od pozadine + Layers to Files Slojevi u datoteke + Legacy Staro + Lens Blur Zamućenje objektiva + Lens Correction Korekcija objektiva + Lens Flare Odsjaj objektiva + Lighting Effects Efekti osvetljenja + Liquify Tečno izobličavanje + Lock Layers Zaključaj slojeve + Lum Svet. + Match Color Uskladi boje + Merge to HDR Pro Spoji u HDR Pro + Mezzotint Mecotinta + Mosaic Mozaik + Motion Blur Zamućenje pokreta + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili pokrenite PhotoCraft pod XWayland-om pomoću `WAYLAND_DISPLAY= photocraft`. + Neon Glow Neonski sjaj + New Guide Layout Novi raspored vodilja + New Video Layer from File Novi video sloj iz datoteke + Ocean Ripple Okeanski talasi + Oil Paint Uljana boja + Open As Otvori kao + Package Spakuj + Paint Daubs Mrlje boje + Path Blur Zamućenje duž putanje + Paths to Illustrator Putanje u Illustrator + Photomerge Spajanje fotografija + Picture Frame Ram za sliku + Pinch Skupljanje + Place Embedded Postavi ugrađeno + Place Linked Postavi povezano + Place Scale Marker Postavi oznaku razmere + Plastic Wrap Plastična folija + Pointillize Poentilizam + Polar Coordinates Polarne koordinate + Polar To Rectangular Iz polarnih u pravougle + Poster Edges Posterske ivice + Preset Manager Upravljač šablonima + Profile Profil + Radial Blur Radijalno zamućenje + Raw Defaults Podrazumevana Raw podešavanja + Rectangular To Polar Iz pravouglih u polarne + Reduce Noise Smanji šum + Relink to File Ponovo poveži sa datotekom + Render Video Renderuj video + Replace Color Zameni boju + Replace Contents Zameni sadržaj + Replace Footage Zameni snimak + Resolve Missing Fonts Razreši fontove koji nedostaju + Ripple Talasanje + Rough Pastels Grube pastele + Sat Zas. + Save a Copy Sačuvaj kopiju + Save for Web (Legacy) Sačuvaj za veb (staro) + Scale Effects Skaliraj efekte + Script Events Manager Upravljač događajima skripti + Search menus Pretraži menije + Seed Seme + Select and Mask Izaberi i maskiraj + Set Measurement Scale Postavi razmeru merenja + Shadows/Highlights Senke/svetla + Shape Blur Zamućenje oblikom + Shear Smicanje + Smart Blur Pametno zamućenje + Smart Sharpen Pametno izoštravanje + Smudge Stick Štapić za razmazivanje + Solid Color Puna boja + Spherize Sferizacija + Spin Blur Rotaciono zamućenje + Spot Reflektor + Sprayed Strokes Raspršeni potezi + Strokes Potezi + Sumi-e Sumi-e + Surface Blur Površinsko zamućenje + Texturizer Teksturizer + Tiles Pločice + Tilt-Shift Tilt-shift + Trace Contour Praćenje konture + Trap Trepovanje + Tree Drvo + Trim Odseci ivice + Turbulent Turbulentno + Twirl Uvrtanje + Unsharp Mask Maska neoštrine + Use File › Open to open an image. Koristite Datoteka › Otvori da otvorite sliku. + Vanishing Point Tačka nestajanja + Variable Data Sets Promenljivi skupovi podataka + Video Frames to Layers Video kadrovi u slojeve + WIA Support WIA podrška + Warp Text Krivljenje teksta + Wind Vetar + ZigZag Cik-cak + Vertical Type Tool Alatka za vertikalni tekst + About {app} O programu {app} + Hide {app} Sakrij {app} + Quit {app} Zatvori {app} + Services Usluge + Hide Others Sakrij ostale + Lut LUT + Cooling L B B Hlađenje (LBB) + Cooling80 Hlađenje (80) + Cooling82 Hlađenje (82) + Hsb HSB + Hsl HSL + Movie Prime Filmski fiksni objektiv + Prime105 Fiksni objektiv 105 + Prime35 Fiksni objektiv 35 + Rgb RGB + Warming L B A Zagrevanje (LBA) + Warming81 Zagrevanje (81) + Warming85 Zagrevanje (85) +@id filter.sharpen.sharpen Izoštri + Good Dobro + Best Najbolje + Adjustment Properties Svojstva korekcije + Default Foreground and Background Colors Podrazumevane boje prednjeg plana i pozadine + Move Layer Pomeri sloj + Rectangular Selection Pravougaona selekcija + Reorder Layer Promeni redosled sloja + Select Layer Izaberi sloj + Set Colors Postavi boje + Switch Foreground and Background Colors Zameni boje prednjeg plana i pozadine + Target Cilj + +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Napravi radnu putanju… + Create Vector Mask Napravi vektorsku masku + Make Selection… Napravi selekciju… + Fill Path… Popuni putanju… + Clipping Path… Putanja isecanja… + Free Transform Path Slobodna transformacija putanje + Unite Shapes at Overlap Ujedini oblike na preklapanju + Subtract Shapes at Overlap Oduzmi oblike na preklapanju + Copy Fill Kopiraj popunu + Copy Complete Stroke Kopiraj kompletan obrub + Paste Fill Nalepi popunu + Paste Complete Stroke Nalepi kompletan obrub + Make Symmetry Path Napravi putanju simetrije + Disable Symmetry Path Onemogući putanju simetrije + Dark Gray Tamnosiva + Medium Gray Srednje siva + Select Custom Color… Izaberi prilagođenu boju… + Color Picker (Custom Canvas Color) Birač boja (prilagođena boja platna) + Duplicate Channel Dupliraj kanal + Delete Channel Obriši kanal + Rename Channel… Preimenuj kanal… + Merge Spot Channel Spoji kanal spot boje + Convert to Alpha Channel Pretvori u alfa kanal + Exit Quick Mask Izađi iz brze maske + New Channel… Novi kanal… + New Spot Channel… Novi kanal spot boje… + Split Channels Razdvoji kanale + Merge Channels… Spoji kanale… + Color Indicates Masked Areas Boja označava maskirane oblasti + Color Indicates Selected Areas Boja označava izabrane oblasti + ✓ Color Indicates Masked Areas ✓ Boja označava maskirane oblasti + ✓ Color Indicates Selected Areas ✓ Boja označava izabrane oblasti + Up Nagore + To Do + Shallow Plitko + Deep Duboko + Blend Interior Effects as Group Stapaj unutrašnje efekte kao grupu + Blend Clipped Layers as Group Stapaj isečene slojeve kao grupu + Transparency Shapes Layer Providnost oblikuje sloj + Layer Mask Hides Effects Maska sloja skriva efekte + Vector Mask Hides Effects Vektorska maska skriva efekte + General Blending Opšte stapanje + This Layer: Ovaj sloj: + Underlying Layer: Sloj ispod: + Nothing to paste: the clipboard holds no image Nema šta da se nalepi: u ostavi nema slike + Preferences saved Podešavanja su sačuvana + Invalid panel layout; restored the default dock Neispravan raspored panela; vraćen je podrazumevani raspored + Preferences were reset: {error} Podešavanja su resetovana: {error} + Recovered {name} Oporavljeno: {name} + Couldn't save preferences: {error}. Retrying… Čuvanje podešavanja nije uspelo: {error}. Ponovni pokušaj… + Autosave failed: {error} Automatsko čuvanje nije uspelo: {error} + Exported presets to {path} Šabloni su izvezeni u {path} + Ran script {name} Pokrenuta skripta {name} + Sent to the printer ({spooler}) Poslato na štampač ({spooler}) + Printed to {path} Odštampano u {path} + Exported {path} ({size}) Izvezeno: {path} ({size}) + Exported {path} Izvezeno: {path} + Added swatch {name} Dodat uzorak boje {name} + Step {step} ({id}) failed: {error} Korak {step} ({id}) nije uspeo: {error} + Could not rename the action: {error} Preimenovanje radnje nije uspelo: {error} + Action playback failed: {error} Izvršavanje radnje nije uspelo: {error} + Clone source set at {x}, {y} Izvor kloniranja postavljen na {x}, {y} + Color management: {error} Upravljanje bojama: {error} + Opened {url} Otvoreno: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nema prilagođenog oblika „{name}“: izaberite jedan na traci opcija ili u Prozor › Oblici + Saved a copy as {path} Kopija je sačuvana kao {path} + Exported contents to {path} Sadržaj je izvezen u {path} + {label} is not available: {reason} {label} nije dostupno: {reason} + Saved {path}; image assets: {n} in {dir} Sačuvano: {path}; slikovni resursi: {n} u {dir} + {message} (notes: {n}; {warning}) {message} (beleške: {n}; {warning}) + Preferences can't be saved Podešavanja ne mogu da se sačuvaju + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft nastavlja da pokušava; dok čuvanje ne uspe, izmene podešavanja se gube pri zatvaranju. + GPU retry scheduled Zakazan je ponovni pokušaj sa GPU-om + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Sačuvajte svoj rad i ponovo pokrenite PhotoCraft da biste ponovo pokušali GPU ubrzanje. Renderovanje procesorom ostaje aktivno do kraja ove sesije. + GPU device was lost; using the CPU renderer. GPU uređaj je izgubljen; koristi se renderovanje procesorom. + GPU error; using the CPU renderer. Greška GPU-a; koristi se renderovanje procesorom. + Couldn't open {name}: {error} Otvaranje {name} nije uspelo: {error} + Toggle the Brush Settings panel Prikaži/sakrij panel Podešavanja četkice + New Brush Preset Novi šablon četkice + Grid view Prikaz mreže sličica + List view Prikaz spiska + {verb} {mask} {layer} {verb} {mask} ({layer}) + Unlink Razveži + Link Poveži + layer mask masku sloja + vector mask vektorsku masku + Commit transform Potvrdi transformaciju + Cancel transform Otkaži transformaciju + Scroll menu up Pomeri meni nagore + Scroll menu down Pomeri meni nadole + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora da se rasterizuje pre nastavka. Njegov tekst više neće moći da se uređuje. Rasterizovati tekst? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora da se rasterizuje pre nastavka. Više neće moći da se uređuje kao oblik. Rasterizovati oblik? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj popune mora da se rasterizuje pre nastavka. Njegov sadržaj popune više neće moći da se uređuje. Rasterizovati sloj? + This layer must be rasterized before proceeding. Ovaj sloj mora da se rasterizuje pre nastavka. + Color Picker (Background Color) Birač boja (boja pozadine) + Color Picker (Foreground Color) Birač boja (boja prednjeg plana) + Adwaita (Light) Adwaita (svetla) + Adwaita (Dark) Adwaita (tamna) + Breeze Light Breeze svetla + Breeze Dark Breeze tamna + Solarized Dark Solarized tamna + Open Recent is unavailable on the web Otvori nedavno nije dostupno na vebu + Graphics adapter: {adapter} Grafički adapter: {adapter} + Backend: {backend} ({type}) Pozadinski sistem: {backend} ({type}) + Driver: {driver} Upravljački program: {driver} + GPU backend preference: {backend} Željeni GPU pozadinski sistem: {backend} + Selected at launch: {backend} Izabrano pri pokretanju: {backend} + Image compositor: {processor} Kompozitor slike: {processor} + Fallback: {reason} Rezervno rešenje: {reason} + This session: {reason} Ova sesija: {reason} + Window renderer: {renderer} Renderer prozora: {renderer} + Software adapter Softverski adapter + Graphics adapter Grafički adapter + unknown nepoznato + unknown type nepoznata vrsta + auto automatski + Platform: {os} {arch} Platforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} zadržano je prethodno očitavanje; poslednje ponovno očitavanje nije uspelo: {reason} + Monitor profile: {profile} Profil monitora: {profile} + Display profiles: {reason} Profili ekrana: {reason} + Monitor profile in use on {display}: {profile} Profil monitora u upotrebi na {display}: {profile} + Monitor profile in use: {profile} Profil monitora u upotrebi: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno na {display} se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + Display profile not used Profil ekrana se ne koristi + Monitor profile on {display} (main window): {profile} Profil monitora na {display} (glavni prozor): {profile} + Monitor profile on {display}: {profile} Profil monitora na {display}: {profile} + manual ručno + fallback rezervno + The document does not have an embedded colour profile. Working space: {working}. Dokument nema ugrađen profil boja. Radni prostor: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument ima ugrađen profil boja koji se ne slaže sa radnim prostorom.\nUgrađeni: {embedded}\nRadni: {working} + Importing {what} from {file}… Uvoz ({what}) iz {file}… + brushes četkice + gradients gradijenti +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Uvezena je {n} četkica iz {file}|Uvezene su {n} četkice iz {file}|Uvezeno je {n} četkica iz {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Uvezen je {n} gradijent iz {file}|Uvezena su {n} gradijenta iz {file}|Uvezeno je {n} gradijenata iz {file} + Data Set: {name} ({index}/{total}) Skup podataka: {name} ({index}/{total}) + (none) (nijedan) +navigator Zoom Uvećanje + Apply {option}: {state} Primeni {option}: {state} + on uključeno + off isključeno + This adjustment ({key}) is kept as imported and can't be edited yet. Ova korekcija ({key}) je zadržana onakva kakva je uvezena i još ne može da se uređuje. + Join the ArtCraft Discord ({url}) Pridružite se ArtCraft Discord zajednici ({url}) + {index} of {total} {index} od {total} + {shortcut} assigned. {shortcut} je dodeljena. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} već koristi {commands} i biće uklonjena odatle kada kliknete U redu. + +# Command-id overrides where the plain key is also a dialog button +@id file.close Zatvori +@id layer.layerMask.apply Primeni +@id edit.clear Obriši + +# Layer colour labels distinct from channel and ink names +layerLabel Red Crvena +layerLabel Magenta Magenta + + Drag: draw freehand · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · Otpuštanje na početnoj tački: zatvori + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · {commit} potvrđuje · Esc otkazuje + +# Character, Paragraph and Glyphs panels + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: metrički, optički ili vrednost u 1/1000 em (Alt+←/→ kod kursora) + Basic Latin Osnovna latinica + Latin 1 Latinica 1 + Latin Extended-A Proširena latinica A + Latin Extended-B Proširena latinica B + Greek Grčko pismo + Cyrillic Ćirilica + Numbers Brojevi + Currency Valute + Math Symbols Matematički simboli + Arrows Strelice + + Save Path Sačuvaj putanju + +# Camera Raw labels +cameraRaw Highlights Svetla +cameraRaw Shadows Senke + +# Keyboard Shortcuts and Menus editor + All shortcuts reset to Photoshop defaults. Sve prečice su vraćene na podrazumevane vrednosti Photoshop-a. + Temporary Privremeno + none nijedna + red crvena + orange narandžasta + yellow žuta + green zelena + blue plava + violet ljubičasta + gray siva + +# Color Settings dialog + Working Rgb Radni RGB + Working Cmyk Radni CMYK + Working Gray Radna siva + Policy Rgb Pravilo za RGB + Policy Cmyk Pravilo za CMYK + Policy Gray Pravilo za sivu + Convert Pretvori + Ask On Mismatch Pitaj pri neslaganju + Ask On Paste Pitaj pri lepljenju + Ask On Missing Pitaj kada profil nedostaje + Intent Način konverzije + Bpc Kompenzacija crne tačke + Blend Text Gamma Gama za stapanje teksta + Monitor Profile Profil monitora + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB (kompatibilno) + Prophoto-compat ProPhoto RGB (kompatibilno) + Linear-srgb Linearni sRGB + Rec2020 Rec. 2020 + Coated-cmyk Premazani CMYK + Sgray sGray + Gray-gamma-2.2 Siva gama 2.2 + + Import Photoshop brushes (.abr) Uvezi Photoshop četkice (.abr) + +# Camera Raw chrome, tone, curve, color mixer, grading, detail and effects +cameraRaw Effects Efekti +cameraRaw Exposure Ekspozicija +cameraRaw Contrast Kontrast +cameraRaw Texture Tekstura +cameraRaw Clarity Jasnoća +cameraRaw Dehaze Uklanjanje izmaglice +cameraRaw Saturation Zasićenost +cameraRaw Curve Kriva +cameraRaw Color Mixer Mešač boja +cameraRaw Midtones Srednji tonovi +cameraRaw Balance Balans +cameraRaw Detail Detalji +cameraRaw Radius Poluprečnik +cameraRaw Feather Omekšavanje +cameraRaw Midpoint Središte +cameraRaw Roundness Zaobljenost +cameraRaw Size Veličina + +# New Document, Home and Image Size + Default Photoshop Size Podrazumevana Photoshop veličina +document Orientation Orijentacija + +# Warp, Liquify, Perspective and Puppet + Arc Lower Donji luk + Arc Upper Gornji luk + Forward Warp Guranje napred + Twirl Clockwise Uvrtanje udesno + Twirl Counterclockwise Uvrtanje ulevo + Pucker Skupljanje + Bloat Naduvavanje + Push Left Guranje ulevo + Freeze Mask Zamrzni masku + Thaw Mask Odmrzni masku + Freeze Lasso Laso za zamrzavanje + Reconstruct All Rekonstruiši sve + Mask None Bez maske + +# Contextual labels +colorRange Invert Invertuj +layers Fill Popuna +layers Fill: Popuna: +shape Appearance Izgled + +# Added on merge with main (spelling, Camera Raw settings files, printing, Object Finder, brush +# controls, actions, gradient presets, Pen Auto Add/Delete) + Add to Dictionary Dodaj u rečnik + No Spelling Suggestions Nema predloga za pravopis + Save Settings… Sačuvaj podešavanja… + Load Settings… Učitaj podešavanja… + Save these settings as a Camera Raw .xmp file Sačuvaj ova podešavanja kao Camera Raw .xmp datoteku + Load settings from a Camera Raw .xmp file Učitaj podešavanja iz Camera Raw .xmp datoteke + Loaded Camera Raw settings from {file} Camera Raw podešavanja su učitana iz {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw podešavanja su učitana iz {file} (nije podržano: {n}) + Manage submitted jobs in the system print queue Upravljajte poslatim poslovima u sistemskom redu za štampu + Pick Color From Image Uzmi boju sa slike + Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje + Object Finder Pronalazač objekata + No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + Clear Brush Controls Obriši kontrole četkice + Reset All Locked Settings Vrati sva zaključana podešavanja + Copy Texture to Other Tools Kopiraj teksturu u druge alatke + Load Actions… Učitaj radnje… + Save Actions… Sačuvaj radnje… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana je {n} radnja iz {file}|Učitane su {n} radnje iz {file}|Učitano je {n} radnji iz {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana je {n} radnja u {file}|Sačuvane su {n} radnje u {file}|Sačuvano je {n} radnji u {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen je {n} korak bez ekvivalenta u Photoshopu|Izostavljena su {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno je {n} koraka bez ekvivalenta u Photoshopu + Insert Stop… Umetni zaustavljanje… + Save Set… Sačuvaj skup… + Message: Poruka: + Allow Continue Dozvoli nastavak + Show dialog during playback Prikaži dijalog tokom puštanja + Select an action first Prvo izaberite radnju + Action playback stopped Puštanje radnje je zaustavljeno + Step {step} is waiting for its dialog Korak {step} čeka svoj dijalog +@plural {n} action|{n} actions {n} radnja|{n} radnje|{n} radnji + Gradient Preset Options Opcije šablona gradijenta + Import .GRD Uvezi .GRD + Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). + Auto Add/Delete Automatsko dodavanje/brisanje diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 0b9118386a..49bc3bc79b 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -98,6 +98,8 @@ mod tests { (vec!["nl-BE", "fr-BE"], "nl"), (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), + (vec!["sr-Latn-RS", "en-US"], "sr"), + (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 58c96b4dc6..9c73eb094b 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 (Italian native review; previously minor: Turkish catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **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), @@ -49,7 +49,9 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde 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 other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` -(3,731 rows) also covers every measured key. +(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 +the Russian rule. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -82,3 +84,4 @@ engine localization or native review is complete. | 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 | 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/localization.md b/docs/localization.md index f05b261b36..36e09f8a55 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -27,6 +27,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Nederlands | `nl` | one / other | | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | +| Srpski | `sr` | one / few / many (teens use many) | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is @@ -47,7 +48,8 @@ The existing preference store saves the selection for future launches. `auto` fo native system locale; an unsupported code follows the same fallback. Regional tags such as `fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the -Brazilian Portuguese catalog. +Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every +Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it. ### First launch and system language diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index f573b316f4..d325b2b9d6 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -17,6 +17,7 @@ es ru uk + sr cs fr id diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 595fc65568..ee337c0b9b 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -582,6 +582,7 @@ mod tests { ("hu", "hu.tsv"), ("tr", "tr.tsv"), ("vi", "vi.tsv"), + ("sr", "sr.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); let text = fs::read_to_string(&catalog_path).unwrap_or_default(); From f5f0f73f25b22bd7c7941d02805fc51b5e8f9dfd Mon Sep 17 00:00:00 2001 From: "Victor S." <46823269+sssehnsuchttt@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:01:08 +0700 Subject: [PATCH 125/223] feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439) * feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). * feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft/Cargo.toml | 3 + apps/photocraft/src/appearance.rs | 2 +- apps/photocraft/src/caption_style.rs | 399 +++++++++++++++++++++++ apps/photocraft/src/icon_theme.rs | 329 +++++++++++++++++++ apps/photocraft/src/main.rs | 7 + contributors/people.toml | 3 + crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/panels.rs | 28 +- crates/ui-egui/src/titlebar.rs | 462 +++++++++++++++++++++++++-- 10 files changed, 1200 insertions(+), 41 deletions(-) create mode 100644 apps/photocraft/src/caption_style.rs create mode 100644 apps/photocraft/src/icon_theme.rs diff --git a/Cargo.lock b/Cargo.lock index a0499ca1c8..fa59477940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4220,6 +4220,7 @@ dependencies = [ "photocraft-raster", "photocraft-tablet", "photocraft-ui-egui", + "resvg", "rfd", "ron", "serde_json", diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 57b537931c..52cb76dbe8 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -68,6 +68,9 @@ photocraft-cms = { workspace = true } x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Wayland desktop color selection through the existing XDG portal transport. zbus = "=5.19.0" +# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no +# text or images. Already in Cargo.lock (photocraft-io); pinned exactly. +resvg = { version = "=0.45.1", default-features = false } # Already used by the portal stack; bounded waits let cancelling close the OS request. futures-lite = "=2.6.1" async-io = "=2.6.0" diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index efaf6ccdb5..aaae41a1f2 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; const PORTAL_KEY: &str = "color-scheme"; #[cfg(target_os = "linux")] -fn portal_proxy() -> Option> { +pub(crate) fn portal_proxy() -> Option> { let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() } diff --git a/apps/photocraft/src/caption_style.rs b/apps/photocraft/src/caption_style.rs new file mode 100644 index 0000000000..5ee16f9f96 --- /dev/null +++ b/apps/photocraft/src/caption_style.rs @@ -0,0 +1,399 @@ +//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on +//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of +//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE +//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's +//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order. +//! +//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and +//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found. + +#[cfg(target_os = "linux")] +use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle}; + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Desktop { + Gnome, + Kde, + Other, +} + +/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and +/// the like count as GNOME. +#[cfg(target_os = "linux")] +fn desktop(var: &dyn Fn(&str) -> Option) -> Desktop { + for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] { + let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue }; + return if v.contains("gnome") { + Desktop::Gnome + } else if v.contains("kde") || v.contains("plasma") { + Desktop::Kde + } else { + Desktop::Other + }; + } + if var("KDE_FULL_SESSION").is_some() { + Desktop::Kde + } else if var("GNOME_DESKTOP_SESSION_ID").is_some() { + Desktop::Gnome + } else { + Desktop::Other + } +} + +/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK +/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons. +#[cfg(target_os = "linux")] +fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily { + match desktop { + Desktop::Gnome => CaptionFamily::Adwaita, + Desktop::Kde => CaptionFamily::Breeze, + Desktop::Other => { + let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase(); + if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) { + CaptionFamily::Adwaita + } else if theme.starts_with("breeze") { + CaptionFamily::Breeze + } else { + CaptionFamily::Generic + } + } + } +} + +/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`). +#[cfg(target_os = "linux")] +fn ini_value(text: &str, section: &str, key: &str) -> Option { + let mut inside = false; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + inside = name == section; + } else if inside + && let Some((k, v)) = line.split_once('=') + && k.trim() == key + { + return Some(v.trim().to_string()); + } + } + None +} + +/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a +/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's +/// defaults: the window menu on the left, minimize, maximize and close on the right. +#[cfg(target_os = "linux")] +fn kwin_layout(kwinrc: &str) -> String { + let side = |key: &str, default: &str| { + let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string()); + letters + .chars() + .filter_map(|c| match c { + 'I' => Some("minimize"), + 'A' => Some("maximize"), + 'X' => Some("close"), + _ => None, + }) + .collect::>() + .join(",") + }; + format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX")) +} + +/// What the style is worked out from. +#[cfg(target_os = "linux")] +#[derive(Clone, Default)] +struct Found { + gtk_theme: Option, + /// A GTK decoration layout. + layout: Option, + icon_theme: Option, +} + +/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own), +/// the desktop's setting, or its default theme. +#[cfg(target_os = "linux")] +fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option) -> Option { + match var("PHOTOCRAFT_CAPTION_ICONS") { + Some(name) if name.trim().eq_ignore_ascii_case("none") => None, + Some(name) => Some(name.trim().to_string()), + None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())), + } +} + +/// The style from what was found; `icons` loads a theme's glyphs. +#[cfg(target_os = "linux")] +fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option, icons: &dyn Fn(&str) -> Option) -> CaptionStyle { + let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref())); + // GNOME's own default shows Close only; GTK's shows all three. + let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" }; + let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string()); + let mut style = CaptionStyle::from_gtk_layout(family, &layout); + style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name)); + style +} + +#[cfg(target_os = "linux")] +fn read_text(path: &std::path::Path) -> Option { + crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's +/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them). +#[cfg(target_os = "linux")] +fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option) -> Found { + let config = var("XDG_CONFIG_HOME") + .filter(|d| std::path::Path::new(d).is_absolute()) + .map(std::path::PathBuf::from) + .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config"))); + let Some(config) = config else { return Found::default() }; + let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default(); + let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name"); + if desktop == Desktop::Kde { + let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default())); + let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme"); + Found { gtk_theme, layout, icon_theme } + } else { + let layout = ini_value(>k, "Settings", "gtk-decoration-layout"); + Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") } + } +} + +/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at +/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them. +#[cfg(target_os = "linux")] +fn load_icons(theme: &str) -> Option { + use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small}; + let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?; + let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from); + let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")]; + if files.iter().all(Option::is_none) { + return None; + } + let render = move |icon: CaptionIcon, px: u32| { + let i = match icon { + CaptionIcon::Minimize => 0, + CaptionIcon::Maximize => 1, + CaptionIcon::Restore => 2, + CaptionIcon::Close => 3, + }; + files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px)) + }; + Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) }) +} + +/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct IconCache(std::sync::Mutex)>>); + +#[cfg(target_os = "linux")] +impl IconCache { + fn get(&self, theme: &str) -> Option { + let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((name, icons)) = last.as_ref() + && name == theme + { + return icons.clone(); + } + let icons = load_icons(theme); + *last = Some((theme.to_string(), icons.clone())); + icons + } +} + +#[cfg(target_os = "linux")] +const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences"; +#[cfg(target_os = "linux")] +const LAYOUT_KEY: &str = "button-layout"; +#[cfg(target_os = "linux")] +const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface"; +#[cfg(target_os = "linux")] +const ICONS_KEY: &str = "icon-theme"; + +/// A string setting from the portal, unwrapping the variants some portals nest it in. +#[cfg(target_os = "linux")] +fn portal_string(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + zbus::zvariant::Value::Str(s) => return Some(s.to_string()), + _ => return None, + } + } + None +} + +/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings, +/// following the button layout and icon theme as they change. A worker does the file and D-Bus +/// work and wakes the UI only when the style changes; the first answer usually lands before the +/// first frame. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + let var = |k: &str| std::env::var(k).ok(); + let desktop = desktop(&var); + let mut found = from_files(desktop, &var); + // Icons need the theme read, which takes tens of milliseconds: the worker brings them. + let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None))); + let wake: Arc> = Arc::new(OnceLock::new()); + let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake)); + let cache = IconCache::default(); + let publish = move |found: &Found| { + let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t)); + let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner); + if *cur != style { + *cur = style; + if let Some(ctx) = waker.get() { + ctx.request_repaint(); + } + } + }; + let (ready, wait) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || { + let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None }; + let Some(proxy) = proxy else { + publish(&found); + let _ = ready.send(()); + return; + }; + // Subscribe before the first read, so a change during start-up isn't lost. + let signals = proxy.receive_signal("SettingChanged").ok(); + let read = |namespace: &str, key: &str| { + let value: Option = proxy.call("Read", &(namespace, key)).ok(); + value.and_then(portal_string) + }; + found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take()); + found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take()); + publish(&found); + let _ = ready.send(()); + for message in signals.into_iter().flatten() { + let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue }; + match (namespace.as_str(), key.as_str()) { + (WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value), + (INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value), + _ => continue, + } + publish(&found); + } + }); + // A fast desktop answers before the first frame; a slow one updates the bar later. + if spawned.is_ok() { + let _ = wait.recv_timeout(std::time::Duration::from_millis(150)); + } + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + current.lock().unwrap_or_else(PoisonError::into_inner).clone() + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use photocraft_ui_egui::Caption::{Close, Maximize, Minimize}; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()) + } + + #[test] + fn the_desktop_comes_from_the_first_variable_set() { + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[])), Desktop::Other); + } + + #[test] + fn the_family_follows_the_desktop_then_the_gtk_theme() { + assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic); + assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic); + } + + #[test] + fn kwin_button_letters_become_a_gtk_layout() { + assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults"); + let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n"; + assert_eq!(kwin_layout(rc), "close,minimize,maximize:"); + assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze")); + assert_eq!(ini_value(rc, "nope", "library"), None); + // Garbage is just no buttons on that side, never a panic. + assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close"); + } + + fn no_icons(_: &str) -> Option { + None + } + + #[test] + fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() { + let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() }; + assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus")); + assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita")); + assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze")); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela")); + // The style carries what the loader returns for that name. + let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| { + (t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) }) + }); + assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus")); + } + + #[test] + fn layouts_and_overrides_resolve_to_a_style() { + let none = env(&[]); + let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons); + assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout"); + let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons); + assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close])); + let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None }; + let other = resolve(Desktop::Other, &found, &none, &no_icons); + assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![])); + let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]); + let s = resolve(Desktop::Gnome, &found, &forced, &no_icons); + assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize])); + // An unknown override name falls back to what was found. + assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze); + } + + #[test] + fn portal_strings_unwrap_nested_variants() { + use zbus::zvariant::{OwnedValue, Value}; + let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close"))))); + assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close")); + assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None); + } + + #[test] + fn settings_files_are_read_from_the_config_dir() { + let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id())); + std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap(); + std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap(); + std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap(); + std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap(); + let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]); + let other = from_files(Desktop::Other, &var); + assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:"))); + let kde = from_files(Desktop::Kde, &var); + assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark"))); + // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found. + assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs new file mode 100644 index 0000000000..cf2ede3b7b --- /dev/null +++ b/apps/photocraft/src/icon_theme.rs @@ -0,0 +1,329 @@ +//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the +//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits, +//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG +//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour. + +use std::path::{Path, PathBuf}; + +/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a +/// theme lists, and the bytes read from one file. +const MAX_THEMES: usize = 32; +const MAX_DIRS: usize = 4096; +/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`). +const SCAN_DEPTH: usize = 3; +const MAX_FILE: u64 = 1 << 20; +/// The size the caption glyphs are drawn at, in the spec's units. +const ICON_SIZE: i64 = 16; + +/// A theme or icon name that stays inside its directory. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small). +pub fn read_small(path: &Path) -> Option> { + use std::io::Read; + let mut bytes = vec![]; + std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?; + Some(bytes) +} + +/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each +/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says. +pub fn base_dirs(var: &dyn Fn(&str) -> Option) -> Vec { + let absolute = |p: &String| Path::new(p).is_absolute(); + let home = var("HOME").filter(absolute).map(PathBuf::from); + let mut dirs = vec![]; + if let Some(home) = &home { + dirs.push(home.join(".icons")); + } + if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) { + dirs.push(data.join("icons")); + } + let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons"))); + dirs +} + +/// One `[section]` of an `index.theme`, as (key, value) pairs. +fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> { + let mut inside = false; + let mut pairs = vec![]; + for line in index.lines().map(str::trim) { + if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + if inside { + break; + } + inside = s == name; + } else if inside && let Some((k, v)) = line.split_once('=') { + pairs.push((k.trim(), v.trim())); + } + } + pairs +} + +fn list(value: Option<&str>) -> impl Iterator { + value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty()) +} + +/// How far a theme directory is from a 16 px icon at scale 1 (the spec's +/// `DirectorySizeDistance`), then its scale: 0 is an exact fit. +fn distance(keys: &[(&str, &str)]) -> (i64, i64) { + let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::().ok()).unwrap_or(default).clamp(0, 1 << 16); + let size = num("Size", 0); + let scale = num("Scale", 1).max(1); + let d = match get("Type").unwrap_or("Threshold") { + "Fixed" => (size * scale - ICON_SIZE).abs(), + "Scalable" => { + let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale); + (min - ICON_SIZE).max(ICON_SIZE - max).max(0) + } + _ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0), + }; + (d, scale) +} + +/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in +/// path order: scalable and symbolic ones first, as icons there fit any size. +fn subdirectories(root: &Path) -> Vec { + let mut found = vec![]; + let mut level = vec![root.to_path_buf()]; + for _ in 0..SCAN_DEPTH { + let mut next = vec![]; + for dir in &level { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.flatten().take(MAX_DIRS) { + let path = entry.path(); + if found.len() + next.len() < MAX_DIRS && path.is_dir() { + next.push(path); + } + } + } + found.extend(next.iter().cloned()); + level = next; + } + let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic"))); + found.sort_by_key(|p| (vector(p), p.clone())); + found +} + +/// One theme of a chain: its listed directories, best fit first, in every base directory that has +/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot. +struct Theme { + roots: Vec, + listed: Vec, + unlisted: std::cell::OnceCell>, +} + +impl Theme { + /// The directories the index forgot to list: GTK still finds icons there, and custom themes + /// often add `symbolic/ui` without listing it. + fn unlisted(&self) -> &[PathBuf] { + self.unlisted.get_or_init(|| { + let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect(); + self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect() + }) + } +} + +/// A theme and the themes it inherits, in search order. +pub struct IconTheme { + themes: Vec, +} + +impl IconTheme { + /// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an + /// `index.theme`. + pub fn load(name: &str, bases: &[PathBuf]) -> Option { + let mut chain: Vec = vec![name.to_string()]; + let mut themes = vec![]; + let mut i = 0; + loop { + let Some(theme) = chain.get(i).cloned() else { + // Every chain ends in hicolor, whether or not the themes before it exist. + if chain.iter().any(|t| t == "hicolor") { + break; + } + chain.push("hicolor".into()); + continue; + }; + i += 1; + if !valid_name(&theme) { + continue; + } + let roots: Vec = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect(); + // The first index wins; a theme can still be spread over several base directories. + let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue }; + let index = String::from_utf8_lossy(&index); + let head = section(&index, "Icon Theme"); + let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories")) + .chain(list(get("ScaledDirectories"))) + .filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty())) + .take(MAX_DIRS) + .map(|d| (distance(§ion(&index, d)), d)) + .collect(); + subdirs.sort_by_key(|(key, _)| *key); + // Not checked for existence: a theme lists hundreds, and the search stops at a hit. + let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect(); + for parent in list(get("Inherits")) { + if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) { + chain.push(parent.to_string()); + } + } + themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() }); + } + (!themes.is_empty()).then_some(Self { themes }) + } + + /// The first `.svg` in search order: each theme's listed directories, then its unlisted + /// ones, before its parent. + pub fn find_svg(&self, icon: &str) -> Option { + if !valid_name(icon) { + return None; + } + let file = format!("{icon}.svg"); + let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file()); + self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted()))) + } +} + +/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an +/// unreadable SVG or an absurd size. +pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { + use resvg::{tiny_skia, usvg}; + if !(1..=256).contains(&px) { + return None; + } + // Glyphs are paths: no `` is loaded, so an icon can't make us read another file + // (`/dev/zero`, a huge picture) or nest an SVG. + let opts = usvg::Options { + image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A throwaway icon tree: `files` are (path under the base, contents). + fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf { + let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for (path, text) in files { + let p = base.join(path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + base + } + + const SQUARE: &str = r##""##; + + #[test] + fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() { + let base = tree( + "chain", + &[ + ("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"), + ("Child/48/a-symbolic.svg", "big"), + // Not in the index: still found, and before the parent's copy. + ("Child/symbolic/ui/d-symbolic.svg", SQUARE), + ("Parent/ui/d-symbolic.svg", SQUARE), + ("Child/16/a-symbolic.svg", SQUARE), + ("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"), + ("Parent/ui/b-symbolic.svg", SQUARE), + ("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"), + ("hicolor/s/c-symbolic.svg", SQUARE), + ], + ); + let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads"); + assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px"); + assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg"))); + assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg"))); + assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own"); + assert_eq!(theme.find_svg("missing"), None); + assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None); + assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers"); + assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + /// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic. + #[test] + fn cyclic_or_hostile_indexes_are_harmless() { + let base = tree( + "hostile", + &[ + ( + "A/index.theme", + "[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n", + ), + ("A/ok/x.svg", SQUARE), + ("B/index.theme", "[Icon Theme]\nInherits=A\n"), + ], + ); + let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads"); + assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg"))); + assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn distance_follows_the_spec() { + assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1)); + assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1)); + assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2)); + assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1)); + assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1)); + assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default"); + } + + #[test] + fn masks_are_square_coverage() { + let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders"); + assert_eq!(mask.len(), 32 * 32); + assert_eq!(mask[16 * 32 + 16], 255, "inside the square"); + assert_eq!(mask[0], 0, "outside it"); + assert!(rasterize_mask(b"not svg", 16).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); + } + + /// An icon's `` is never loaded: not a device file, not an embedded picture. + #[test] + fn masks_load_no_images() { + let svg = r##" + + + "##; + let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders"); + assert!(mask.iter().all(|a| *a == 0), "nothing drawn"); + } + + #[test] + fn base_dirs_follow_xdg() { + let var = |k: &str| match k { + "HOME" => Some("/home/u".to_string()), + "XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()), + _ => None, + }; + let dirs = base_dirs(&var); + let want: Vec = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect(); + assert_eq!(dirs, want); + assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]); + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 00d334758b..938fc1f032 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -26,11 +26,14 @@ mod appearance; #[cfg(target_os = "macos")] mod apple_events; mod caps_lock; +mod caption_style; mod control_server; mod crash_guard; mod cursor; mod file_dialog_probe; mod gpu_startup; +#[cfg(target_os = "linux")] +mod icon_theme; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] @@ -492,6 +495,10 @@ fn main() -> eframe::Result { // The custom title bar's undecorated window must stay borderless, or its caption and // border offset the content and the pointer on some Windows systems (#2246). app.services.window_frame = window_frame::service(cc, custom_titlebar); + // Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them. + if custom_titlebar { + app.services.caption_style = caption_style::service(); + } // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the // precise crosshair for painting tools while it is toggled on (#1758). app.services.caps_lock = caps_lock::service(cc); diff --git a/contributors/people.toml b/contributors/people.toml index 30191f52ae..7b75c991fd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -147,3 +147,6 @@ sync_github_name = true [people.megaDon101] display_name = "megadon" sync_github_name = false + +[people.sssehnsuchttt] +sync_github_name = true diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea40171b9a..55be083d42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,7 @@ pub mod tiff_options_ui; mod tile_overlay; mod timeline_ui; mod titlebar; +pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; mod tool_cursor; pub mod tool_feedback; @@ -379,6 +380,8 @@ pub type CapsLockFn = Box bool>; /// Keeps an undecorated window's OS frame from offsetting its content and pointer (#2246). /// Called once per frame; `None` when the window is decorated or the platform needs no fixing. pub type WindowFrameFn = Box; +/// The caption buttons' current style ([`Services::caption_style`]). +pub type CaptionStyleFn = Box CaptionStyle>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -455,6 +458,10 @@ pub struct Services { /// (Windows custom title bar, #2246). Called once per frame; `None` when the window is /// decorated or the platform needs no fixing. pub window_frame: Option, + /// How the custom title bar's caption buttons look and where they sit (Linux: the desktop's + /// style and button layout). Read every frame, with the context to wake when it changes; + /// `None` keeps Windows' buttons at the right end. + pub caption_style: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 3624dbbf5a..dbf53b0f55 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -550,17 +550,25 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { /// The app's top bar: brand mark, menus, the document title and the workspace controls. With /// [`PhotocraftApp::custom_titlebar`] (Windows and Linux) it is also the window's title bar, as in -/// Photoshop on Windows: the caption buttons take its right end (`titlebar`). With the system +/// Photoshop on Windows: the caption buttons take its ends (`titlebar`). With the system /// title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and title, so the /// in-app bar shows menus and workspace controls only: no brand mark, no centred title. pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let custom = app.custom_titlebar; let system = system_title_bar(app); - let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 }; - let right = if custom { 0 } else { 10 }; + // The caption buttons' room at each end (`titlebar`); a side with buttons has no margin. + let (caption_left, caption_right) = crate::titlebar::reserved(app, ui.ctx()); + let left = if cfg!(target_os = "macos") && app.integrated_titlebar { + 78 + } else if caption_left > 0.0 { + 0 + } else { + 10 + }; + let right = if caption_right > 0.0 { 0 } else { 10 }; let bar = egui::Panel::top("title_bar") - .exact_size(if t.pro { 32.0 } else { 38.0 }) + .exact_size(crate::titlebar::bar_height(app, ui.ctx(), if t.pro { 32.0 } else { 38.0 })) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); @@ -586,9 +594,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let rtl = crate::direction::is_rtl(); let mut free = (full.left(), full.right()); crate::direction::horizontal_centered(ui, |ui| { - if rtl && custom { - // The caption buttons keep the right end; the menus start beside them. - ui.add_space(crate::titlebar::WIDTH + 4.0); + // The menus start beside the caption buttons at their end, if it has any. + let lead = if rtl { caption_right } else { caption_left }; + if lead > 0.0 { + ui.add_space(lead + 4.0); } if !system { let side = if t.pro { 18.0 } else { 20.0 }; @@ -606,9 +615,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The menu bar takes the whole row, so the far-end group gets its own rect: from // the menus to the bar's end, or to the caption buttons. let group = if rtl { - egui::Rect::from_min_max(egui::pos2(full.left(), full.top()), egui::pos2((menus_end - TITLE_GAP).max(full.left()), full.bottom())) + let left_edge = if caption_left > 0.0 { full.left() + caption_left + 4.0 } else { full.left() }; + egui::Rect::from_min_max(egui::pos2(left_edge, full.top()), egui::pos2((menus_end - TITLE_GAP).max(left_edge), full.bottom())) } else { - let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() }; + let right_edge = if caption_right > 0.0 { full.right() - caption_right - 4.0 } else { full.right() }; egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom())) }; let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing())); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index d02536aeef..db1963f137 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -1,35 +1,217 @@ //! The window's own title bar on Windows and Linux, where the window has no OS decorations //! ([`PhotocraftApp::custom_titlebar`]): the app's top bar (`panels::title_bar`) is the title bar, //! as in Photoshop on Windows. The free gap between its menus and controls drags the window and a -//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons at -//! the far right minimize, maximize/restore and close, and thin invisible zones along the window's -//! edges resize it. +//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons +//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize +//! it. +//! +//! The caption buttons follow the desktop ([`CaptionStyle`], from `Services::caption_style`): +//! Windows' 46 pt buttons at the right end by default; on Linux libadwaita's round buttons (GNOME), +//! Breeze's (KDE) or a plain round set elsewhere, with the set of buttons and their side taken +//! from the desktop's button layout. The shapes are drawn here, not read from the icon theme. -use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use serde_json::json; use crate::PhotocraftApp; use crate::theme::Tokens; -/// One caption button: as wide as the native Windows ones. +/// One Windows caption button: as wide as the native ones. pub const BUTTON_WIDTH: f32 = 46.0; -/// The three caption buttons together. -pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Resize zone thickness along an edge, and the side of a corner zone. 5 pt was hard to find /// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt. const EDGE: f32 = 8.0; const CORNER: f32 = 12.0; +/// Breeze's Close button on hover and press (KDE's Breeze decoration colours). +const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2); +const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29); -#[derive(Clone, Copy, PartialEq, Debug)] -enum Caption { +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Caption { Minimize, Maximize, Close, } -/// Left to right. +/// Windows' order, left to right. const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; +/// Which desktop's caption buttons to look like. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CaptionFamily { + /// Windows 10/11: full-height buttons 46 pt wide; Close turns red on hover. + #[default] + Windows, + /// GNOME (libadwaita): 24 pt round buttons on a faint disc that darkens on hover and press. + Adwaita, + /// KDE (Breeze decoration): smaller round buttons, bare at rest, inverted on hover. + Breeze, + /// Other Linux desktops: libadwaita's buttons without the disc at rest. + Generic, +} + +impl CaptionFamily { + /// `windows`, `adwaita`, `breeze` or `generic`, in any case. + pub fn parse(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "windows" => Some(Self::Windows), + "adwaita" => Some(Self::Adwaita), + "breeze" => Some(Self::Breeze), + "generic" => Some(Self::Generic), + _ => None, + } + } + + /// The button side, gap on each side of it, and gap between the outermost button and the + /// window's edge. libadwaita: 24 pt buttons 13 pt apart, 10 pt from the edge (the gap is split + /// between neighbours, so the hit areas touch). Breeze sizes buttons by the font (about 1.3 em, + /// 18 pt at KDE's default 10 pt font), half a button apart, 8 pt from the edge. + fn metrics(self) -> (f32, f32, f32) { + match self { + Self::Windows => (BUTTON_WIDTH, 0.0, 0.0), + Self::Adwaita | Self::Generic => (24.0, 6.5, 10.0), + Self::Breeze => (18.0, 4.5, 8.0), + } + } +} + +/// The caption buttons' look and placement. +#[derive(Clone, PartialEq, Debug)] +pub struct CaptionStyle { + pub family: CaptionFamily, + /// The buttons at the bar's left end, left to right. + pub left: Vec, + /// The buttons at the bar's right end, left to right. + pub right: Vec, + /// The round buttons' glyphs from the desktop's icon theme; `None`, or an icon it lacks, draws + /// the family's own. + pub icons: Option, +} + +/// A caption button glyph, as the icon themes name them (`window-restore-symbolic`, …). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum CaptionIcon { + Minimize, + Maximize, + Restore, + Close, +} + +/// Renders an icon as a square coverage mask (`side` × `side` bytes, row by row), or `None`. +pub type RenderIconFn = dyn Fn(CaptionIcon, u32) -> Option> + Send + Sync; + +/// Glyphs from an icon theme (Linux: `window-*-symbolic`), drawn in the button's ink colour as +/// symbolic icons are. +#[derive(Clone)] +pub struct CaptionIcons { + /// The theme's name; the cached textures follow it. + pub theme: String, + pub render: std::sync::Arc, +} + +impl PartialEq for CaptionIcons { + fn eq(&self, other: &Self) -> bool { + self.theme == other.theme && std::sync::Arc::ptr_eq(&self.render, &other.render) + } +} + +impl std::fmt::Debug for CaptionIcons { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CaptionIcons").field("theme", &self.theme).finish_non_exhaustive() + } +} + +impl Default for CaptionStyle { + fn default() -> Self { + Self { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None } + } +} + +impl CaptionStyle { + /// `family` with the buttons a GTK decoration layout names (`gtk-decoration-layout`, GNOME's + /// `button-layout`, e.g. `appmenu:minimize,maximize,close`): those before the colon go left, + /// those after it right; without a colon all go left, as in GTK. Names other than `minimize`, + /// `maximize` and `close` (`appmenu`, `icon`, `spacer`, …) are skipped, and a button named + /// twice is placed once. + pub fn from_gtk_layout(family: CaptionFamily, layout: &str) -> Self { + let (left, right) = layout.split_once(':').unwrap_or((layout, "")); + let mut seen = vec![]; + let mut side = |names: &str| { + let mut v = vec![]; + for name in names.split(',') { + let c = match name.trim() { + "minimize" => Caption::Minimize, + "maximize" => Caption::Maximize, + "close" => Caption::Close, + _ => continue, + }; + if !seen.contains(&c) { + seen.push(c); + v.push(c); + } + } + v + }; + let left = side(left); + let right = side(right); + Self { family, left, right, icons: None } + } +} + +/// The caption style the platform reports, or Windows' when it reports none. +fn style(app: &PhotocraftApp, ctx: &egui::Context) -> CaptionStyle { + app.services.caption_style.as_ref().map(|read| read(ctx)).unwrap_or_default() +} + +/// The title bar's height: `base`, or with GNOME's buttons at least libadwaita's header bar +/// (46 pt, plus the bar's 1 pt separator), so the bar is as tall as its neighbours' title bars. +pub fn bar_height(app: &PhotocraftApp, ctx: &egui::Context, base: f32) -> f32 { + if app.custom_titlebar && style(app, ctx).family == CaptionFamily::Adwaita { base.max(47.0) } else { base } +} + +/// The width the caption buttons take at the bar's (left, right) end: zero for a side without +/// buttons, and for both without the custom title bar. +pub fn reserved(app: &PhotocraftApp, ctx: &egui::Context) -> (f32, f32) { + if !app.custom_titlebar { + return (0.0, 0.0); + } + let s = style(app, ctx); + let width = |side: &[Caption]| slots(s.family, side, 0.0, true).last().map_or(0.0, |(_, r, _)| r.1); + (width(&s.left), width(&s.right)) +} + +/// Each button's horizontal extent (left, right) and face centre on a side starting at `from`, in +/// order. `outer_left` puts the edge gap on the first button's left (the left end) instead of the +/// last button's right. +fn slots(family: CaptionFamily, side: &[Caption], from: f32, outer_left: bool) -> Vec<(Caption, (f32, f32), f32)> { + let (size, gap, end) = family.metrics(); + let mut x = from; + let n = side.len(); + side.iter() + .enumerate() + .map(|(i, &c)| { + let before = if outer_left && i == 0 { end } else { gap }; + let after = if !outer_left && i + 1 == n { end } else { gap }; + let slot = (x, x + before + size + after); + let centre = x + before + size / 2.0; + x = slot.1; + (c, slot, centre) + }) + .collect() +} + +/// Every caption button's hit rect (full bar height, flush with the window's edge) and face +/// centre, for a bar `bar` between the window's `left` and `right` edges. +fn layout(s: &CaptionStyle, bar: Rect, left: f32, right: f32) -> Vec<(Caption, Rect, Pos2)> { + let rect = |(c, (l, r), centre): (Caption, (f32, f32), f32)| { + (c, Rect::from_min_max(pos2(l, bar.top()), pos2(r, bar.bottom() - 1.0)), pos2(centre, bar.center().y)) + }; + let lefts = slots(s.family, &s.left, left, true); + let width = slots(s.family, &s.right, 0.0, false).last().map_or(0.0, |(_, r, _)| r.1); + let rights = slots(s.family, &s.right, right - width, false); + lefts.into_iter().chain(rights).map(rect).collect() +} + /// Caption button ids (stable, so tests and agents can find them). fn caption_id(c: Caption) -> Id { Id::new(("titlebar-caption", c as u8)) @@ -43,29 +225,38 @@ fn toggle_maximize(ctx: &egui::Context) { ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx))); } -/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title bar's outer rect), -/// full bar height, flush with the window's edge (even if the bar's contents run wider) so a -/// maximized window's corner hits Close. Close runs File › Exit, which asks about unsaved -/// documents first. +/// The caption buttons at the ends of `bar` (the title bar's outer rect), full bar height, flush +/// with the window's edges (even if the bar's contents run wider) so a maximized window's corner +/// hits the outermost one. Close runs File › Exit, which asks about unsaved documents first. On +/// Linux the buttons drag the window too, as GTK's and KDE's do. pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { let t = Tokens::get(ui.ctx()); + let s = style(app, ui.ctx()); let max = maximized(ui.ctx()); - let right = bar.right().min(ui.ctx().content_rect().right()); + let content = ui.ctx().content_rect(); + let windows = s.family == CaptionFamily::Windows; let mut clicked = None; - for (i, c) in CAPTIONS.into_iter().enumerate() { - let r = Rect::from_min_size(pos2(right - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0)); - let resp = ui.interact(r, caption_id(c), Sense::click()); - let close = c == Caption::Close; - let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { - (true, _) if close => t.caption_close.gamma_multiply(0.8), - (_, true) if close => t.caption_close, - (true, _) => t.pressed, - (_, true) => t.hover, - _ => egui::Color32::TRANSPARENT, - }; - ui.painter().rect_filled(r, 0.0, fill); - let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; - paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) { + let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() }); + if resp.drag_started_by(PointerButton::Primary) { + ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + } + let pressed = resp.is_pointer_button_down_on() && resp.hovered(); + if windows { + let close = c == Caption::Close; + let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { + (true, _) if close => t.caption_close.gamma_multiply(0.8), + (_, true) if close => t.caption_close, + (true, _) => t.pressed, + (_, true) => t.hover, + _ => Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; + paint_windows_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + } else { + paint_round(ui, &t, &s, c, max, centre, resp.hovered(), pressed); + } let tip = match c { Caption::Minimize => tl!("Minimize"), Caption::Maximize if max => tl!("Restore"), @@ -88,8 +279,8 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { } } -/// The 10 pt line glyphs of the caption buttons. -fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { +/// The 10 pt line glyphs of the Windows caption buttons. +fn paint_windows_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { let p = ui.painter(); let g = Rect::from_center_size(at, vec2(10.0, 10.0)); match c { @@ -112,6 +303,119 @@ fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) } } +/// A round Linux caption button centred at `centre`: its disc, then its glyph on a 16 pt grid. +/// The disc is the icon colour at a fraction of its opacity (libadwaita's `alpha(currentColor)`), +/// so it suits light and dark themes alike; Breeze instead fills it with the icon colour on hover +/// and draws the glyph in the bar's colour. +#[allow(clippy::too_many_arguments)] +fn paint_round(ui: &Ui, t: &Tokens, s: &CaptionStyle, c: Caption, maximized: bool, centre: Pos2, hovered: bool, pressed: bool) { + let family = s.family; + let (size, _, _) = family.metrics(); + // Whole pixels, so the glyphs' edges stay sharp. + let ppp = ui.ctx().pixels_per_point(); + let centre = pos2((centre.x * ppp).round() / ppp, (centre.y * ppp).round() / ppp); + let close = c == Caption::Close; + let (fill, ink) = if family == CaptionFamily::Breeze { + match (hovered, pressed) { + (_, true) if close => (BREEZE_CLOSE_PRESSED, t.chrome), + (true, _) if close => (BREEZE_CLOSE_HOVER, t.chrome), + (_, true) => (t.icon.gamma_multiply(0.5), t.chrome), + (true, _) => (t.icon, t.chrome), + _ => (Color32::TRANSPARENT, t.icon), + } + } else { + let rest = if family == CaptionFamily::Adwaita { 0.10 } else { 0.0 }; + let target = if pressed { + 0.30 + } else if hovered { + 0.15 + } else { + rest + }; + // libadwaita eases the disc over 0.2 s. + let a = ui.ctx().animate_value_with_time(caption_id(c).with("disc"), target, 0.2); + (t.icon.gamma_multiply(a), t.icon) + }; + let p = ui.painter(); + if fill.a() > 0 { + p.circle_filled(centre, size / 2.0, fill); + } + // The glyph grid: 16 pt, or 2 pt less than a smaller button. + let k = (size - 2.0).min(16.0) / 16.0; + let icon = match c { + Caption::Minimize => CaptionIcon::Minimize, + Caption::Maximize if maximized => CaptionIcon::Restore, + Caption::Maximize => CaptionIcon::Maximize, + Caption::Close => CaptionIcon::Close, + }; + if let Some(icons) = &s.icons + && let Some(tex) = theme_icon(ui.ctx(), icons, icon, (16.0 * k * ppp).round() as u32) + { + let side = tex.size_vec2() / ppp; + let min = centre - side / 2.0; + let rect = Rect::from_min_size(pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp), side); + p.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), ink); + return; + } + let at = |x: f32, y: f32| centre + vec2(x - 8.0, y - 8.0) * k; + if family == CaptionFamily::Breeze { + // Breeze: 1 pt chevrons (down to minimize, up to maximize), a diamond to restore, an X. + let s = Stroke::new(1.0, ink); + match c { + Caption::Minimize => { + p.line(vec![at(2.5, 5.5), at(8.0, 11.0), at(13.5, 5.5)], s); + } + Caption::Maximize if maximized => { + p.add(egui::Shape::closed_line(vec![at(8.0, 3.0), at(13.0, 8.0), at(8.0, 13.0), at(3.0, 8.0)], s)); + } + Caption::Maximize => { + p.line(vec![at(2.5, 10.5), at(8.0, 5.0), at(13.5, 10.5)], s); + } + Caption::Close => { + let s = Stroke::new(1.2, ink); + p.line_segment([at(3.6, 3.6), at(12.4, 12.4)], s); + p.line_segment([at(12.4, 3.6), at(3.6, 12.4)], s); + } + } + } else { + // libadwaita: a 2 pt bar low down, a 2 pt square outline (smaller to restore), an X. + let frame = |a: f32, b: f32| Rect::from_min_max(at(a, a), at(b, b)); + match c { + Caption::Minimize => { + p.rect_filled(Rect::from_min_max(at(4.0, 10.0), at(12.0, 12.0)), 0.0, ink); + } + Caption::Maximize => { + let square = if maximized { frame(5.0, 11.0) } else { frame(4.0, 12.0) }; + p.rect_stroke(square, 0.0, Stroke::new(2.0 * k, ink), egui::StrokeKind::Inside); + } + Caption::Close => { + let s = Stroke::new(1.5 * k, ink); + p.line_segment([at(4.5, 4.5), at(11.5, 11.5)], s); + p.line_segment([at(11.5, 4.5), at(4.5, 11.5)], s); + } + } + } +} + +/// `icon` from the theme at `px` device pixels, uploaded once per theme and size; `None` (also +/// remembered) when the theme lacks it or it doesn't render. +fn theme_icon(ctx: &egui::Context, icons: &CaptionIcons, icon: CaptionIcon, px: u32) -> Option { + let id = Id::new(("caption-icon", &icons.theme, icon, px)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { + return cached; + } + let side = usize::try_from(px).ok().filter(|&s| (1..=256).contains(&s)); + let tex = side.and_then(|side| { + let mask = (icons.render)(icon, px).filter(|m| m.len() == side * side)?; + // White at the mask's coverage, tinted with the ink when drawn. + let pixels = mask.into_iter().map(Color32::from_white_alpha).collect(); + let image = egui::ColorImage { size: [side, side], source_size: vec2(px as f32, px as f32), pixels }; + Some(ctx.load_texture(format!("caption-{icon:?}-{px}"), image, egui::TextureOptions::LINEAR)) + }); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + /// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners /// come last so they win where they overlap the edges. Edges use the one-sided cursors /// (`w-resize`, …), as GTK and KDE window borders do: some cursor themes ship no `ew-resize` / @@ -465,4 +769,100 @@ mod tests { assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); assert!(w.click(pos2(999.5, 0.5)).iter().any(|c| matches!(c, ViewportCommand::Close))); } + + #[test] + fn gtk_layouts_pick_the_buttons_and_their_sides() { + use Caption::*; + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:minimize,maximize,close"); + assert_eq!((s.left, s.right), (vec![], vec![Minimize, Maximize, Close])); + // Unknown names are skipped, spaces trimmed, a repeat placed once, a second colon is a name. + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "close,close , minimize:icon,maximize:close"); + assert_eq!((s.left, s.right), (vec![Close, Minimize], vec![])); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "minimize,close"); + assert_eq!((s.left, s.right), (vec![Minimize, Close], vec![]), "no colon: GTK puts them all at the start"); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, ""); + assert!(s.left.is_empty() && s.right.is_empty()); + assert_eq!(CaptionFamily::parse(" Breeze "), Some(CaptionFamily::Breeze)); + assert_eq!(CaptionFamily::parse("aqua"), None); + assert_eq!(CaptionStyle::default(), CaptionStyle { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None }); + } + + #[test] + fn linux_buttons_sit_where_the_layout_says_and_drag_the_window() { + use Caption::*; + for (family, size, gap, end) in [(CaptionFamily::Adwaita, 24.0, 6.5, 10.0), (CaptionFamily::Breeze, 18.0, 4.5, 8.0)] { + let width = 1100.0; + let mut w = Win::new(width); + w.app.services.caption_style = Some(Box::new(move |_| CaptionStyle { family, left: vec![Close], right: vec![Minimize, Maximize], icons: None })); + w.frame(vec![]); + w.frame(vec![]); + let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); + // Flush with the window's edges, the edge gap outermost, the hit areas touching. + assert_eq!((close.left(), close.width()), (0.0, end + size + gap), "{family:?}"); + assert_eq!((max.right(), max.width()), (width, gap + size + end), "{family:?}"); + assert_eq!((min.right(), min.width()), (max.left(), 2.0 * gap + size), "{family:?}"); + // GNOME's header bars are taller than Photoshop's bar; KDE's are not. + if family == CaptionFamily::Adwaita { + assert_eq!(close.height(), 46.0); + } else { + assert!((31.0..46.0).contains(&close.height()), "{close:?}"); + } + let mark = crate::brand::mark_rect(&w.ctx).expect("the brand mark is painted"); + assert!(mark.left() > close.right(), "the bar starts after the left buttons: {mark:?} {close:?}"); + for a in w.bar_widgets() { + assert!(a.left() >= close.right() && a.right() <= min.left(), "{a:?} runs into the caption buttons ({family:?})"); + } + assert!(w.click(max.center()).contains(&ViewportCommand::Maximized(true))); + assert!(w.click(close.center()).iter().any(|c| matches!(c, ViewportCommand::Close))); + // A drag that starts on a button moves the window instead of pressing it. + let at = min.center(); + let cmds = w.gesture(&[at, at + vec2(12.0, 4.0), at + vec2(30.0, 8.0)]); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + assert!(!cmds.contains(&ViewportCommand::Minimized(true)), "{cmds:?}"); + } + } + + /// A side without buttons reserves nothing; without the custom title bar, neither does any. + #[test] + fn only_sides_with_buttons_take_room() { + let mut w = Win::new(1000.0); + w.app.services.caption_style = Some(Box::new(|_| CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:close"))); + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 6.5 + 24.0 + 10.0)); + w.app.custom_titlebar = false; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 0.0)); + w.app.custom_titlebar = true; + w.app.services.caption_style = None; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 3.0 * BUTTON_WIDTH)); + } + + /// Theme glyphs upload once per theme and size; an icon the theme lacks, a wrong-sized mask or + /// an absurd size gives `None`, so the button draws its own glyph. + #[test] + fn theme_icons_are_cached_and_fall_back() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let render = move |icon: CaptionIcon, px: u32| { + counter.fetch_add(1, Ordering::Relaxed); + match icon { + CaptionIcon::Close => Some(vec![255; (px * px) as usize]), + CaptionIcon::Minimize => Some(vec![255; 3]), + _ => None, + } + }; + let icons = CaptionIcons { theme: "Test".into(), render: std::sync::Arc::new(render) }; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(RawInput::default(), |ui| { + let tex = theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).expect("rendered"); + assert_eq!(tex.size(), [16, 16]); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).is_some()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Minimize, 16).is_none(), "a mask of the wrong size"); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 0).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 100_000).is_none()); + }); + out.textures_delta.clear(); + assert_eq!(calls.load(Ordering::Relaxed), 3, "each icon and size rendered once; absurd sizes never"); + } } From e8af9d592fe32e2634375f9c4dcbd28b133704d1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 05:06:37 +0200 Subject: [PATCH 126/223] Themes: Soft Dark and Soft Light (#4368) (#4378) * Themes: Soft Dark and Soft Light (#4368) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + crates/automation/src/server.rs | 2 +- crates/engine/src/prefs.rs | 22 ++++- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/prefs_ui.rs | 15 ++- crates/ui-egui/src/theme.rs | 143 +++++++++++++++++++++++++++- docs/scorecard.md | 4 +- docs/ui-design.md | 4 +- scorecard/ui.toml | 4 +- 28 files changed, 260 insertions(+), 17 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index aa9f590ceb..227aee8ac9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -54,6 +54,7 @@ next to them. | `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | +| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | ### Optional build input: craft-fonts (not files in this repo) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 74b3770dff..ea1b40239d 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -309,7 +309,7 @@ pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, - /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab, + /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one /// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3a8b7ce695..4db0f248a9 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -55,10 +55,10 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript); choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic); choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe); -choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark" } default ProMedium); +choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark", SoftDark = "softDark", SoftLight = "softLight" } default ProMedium); choice!(AppearanceMode { Auto = "auto", Dark = "dark", Light = "light" } default Dark); -choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark" } default ProMedium); -choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight); +choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark", SoftDark = "softDark" } default ProMedium); +choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight", SoftLight = "softLight" } default StudioLight); choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default); choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow); choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); @@ -1542,8 +1542,12 @@ impl Session { && let Some(theme) = interface.get("theme").and_then(Value::as_str).and_then(Theme::parse) { let (mode, slot) = match theme { - Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark => ("dark", "darkTheme"), - Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight => ("light", "lightTheme"), + Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark | Theme::SoftDark => { + ("dark", "darkTheme") + } + Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight | Theme::SoftLight => { + ("light", "lightTheme") + } }; interface.insert("appearanceMode".into(), json!(mode)); interface.insert(slot.into(), json!(theme.name())); @@ -1666,6 +1670,14 @@ impl Session { next.interface.light_theme = LightTheme::BreezeLight; next.interface.appearance_mode = AppearanceMode::Light; } + Theme::SoftDark => { + next.interface.dark_theme = DarkTheme::SoftDark; + next.interface.appearance_mode = AppearanceMode::Dark; + } + Theme::SoftLight => { + next.interface.light_theme = LightTheme::SoftLight; + next.interface.appearance_mode = AppearanceMode::Light; + } } } self.prefs.edit(|p| *p = next); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6ebb2963c..4fdabb4406 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -735,6 +735,10 @@ Breeze Dark Theme Tmavý motiv Breeze Breeze light Breeze světlý Breeze Light Theme Světlý motiv Breeze + Soft dark Jemný tmavý + Soft light Jemný světlý + Soft Dark Theme Jemné tmavé téma + Soft Light Theme Jemné světlé téma Next Theme Další motiv Actions Akce Brush Settings Nastavení štětce diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a8efbdd687..be910dda32 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -721,6 +721,10 @@ Breeze Dark Theme Breeze-Design (Dunkel) Breeze light Breeze hell Breeze Light Theme Breeze-Design (Hell) + Soft dark Weich dunkel + Soft light Weich hell + Soft Dark Theme Weiches Design (Dunkel) + Soft Light Theme Weiches Design (Hell) Next Theme Nächstes Design Actions Aktionen Brush Settings Pinseleinstellungen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ac29108ad3..d02c1318aa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2132,6 +2132,10 @@ Breeze Dark Theme Σκούρο θέμα Breeze Breeze light Breeze φωτεινό Breeze Light Theme Φωτεινό θέμα Breeze + Soft dark Απαλό σκούρο + Soft light Απαλό ανοιχτό + Soft Dark Theme Απαλό σκούρο θέμα + Soft Light Theme Απαλό ανοιχτό θέμα Clear Canvas Guides Απαλοιφή οδηγών καμβά Clear Override Απαλοιφή παράκαμψης Clear Samples Απαλοιφή δειγμάτων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0a87b52435..cdee7d44b9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -728,6 +728,10 @@ Breeze Dark Theme Tema Breeze oscuro Breeze light Breeze claro Breeze Light Theme Tema Breeze claro + Soft dark Suave oscuro + Soft light Suave claro + Soft Dark Theme Tema suave oscuro + Soft Light Theme Tema suave claro Next Theme Tema siguiente Actions Acciones Brush Settings Ajustes del pincel diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a8e9e2bf3b..daf5447d76 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -718,6 +718,10 @@ Breeze Dark Theme Thème Breeze sombre Breeze light Breeze clair Breeze Light Theme Thème Breeze clair + Soft dark Doux sombre + Soft light Doux clair + Soft Dark Theme Thème doux sombre + Soft Light Theme Thème doux clair Next Theme Thème suivant Actions Actions Brush Settings Paramètres du pinceau diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9476ed77a2..ef7905b881 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -443,6 +443,10 @@ Box Blur… Dobozelmosás… Breeze Dark Theme Breeze sötét téma Breeze Light Theme Breeze világos téma + Soft dark Lágy sötét + Soft light Lágy világos + Soft Dark Theme Lágy sötét téma + Soft Light Theme Lágy világos téma Brush Preview Ecset előnézete Brush Settings Ecsetbeállítások Brushes Ecsetek diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f4c53e49b3..db948970fe 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -711,6 +711,10 @@ Breeze Dark Theme Tema Breeze Gelap Breeze light Breeze terang Breeze Light Theme Tema Breeze Terang + Soft dark Lembut gelap + Soft light Lembut terang + Soft Dark Theme Tema lembut gelap + Soft Light Theme Tema lembut terang Next Theme Tema Berikutnya Actions Action Brush Settings Pengaturan Kuas diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5b12254b11..cf4c8962cf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -726,6 +726,10 @@ Breeze Dark Theme Tema Breeze scuro Breeze light Breeze chiaro Breeze Light Theme Tema Breeze chiaro + Soft dark Morbido scuro + Soft light Morbido chiaro + Soft Dark Theme Tema morbido scuro + Soft Light Theme Tema morbido chiaro Next Theme Tema successivo Actions Azioni Brush Settings Impostazioni pennello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c6a1ac7778..19c36d54ab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -728,6 +728,10 @@ Breeze Dark Theme Breeze ダークテーマ Breeze light Breeze ライト Breeze Light Theme Breeze ライトテーマ + Soft dark ソフトダーク + Soft light ソフトライト + Soft Dark Theme ソフトダークテーマ + Soft Light Theme ソフトライトテーマ Next Theme 次のテーマ Actions アクション Brush Settings ブラシ設定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 166a2c7942..7857b418b7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -702,6 +702,10 @@ Breeze Dark Theme Breeze 어두운 테마 Breeze light Breeze 밝게 Breeze Light Theme Breeze 밝은 테마 + Soft dark 소프트 다크 + Soft light 소프트 라이트 + Soft Dark Theme 소프트 다크 테마 + Soft Light Theme 소프트 라이트 테마 Next Theme 다음 테마 Actions 액션 Brush Settings 브러시 설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 10ebe0fd50..a7f7c2ae13 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -707,6 +707,10 @@ Breeze Dark Theme Breeze-thema donker Breeze light Breeze licht Breeze Light Theme Breeze-thema licht + Soft dark Zacht donker + Soft light Zacht licht + Soft Dark Theme Zacht donker thema + Soft Light Theme Zacht licht thema Adwaita Light Theme Adwaita-thema licht Adwaita Dark Theme Adwaita-thema donker Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4bf1a55886..0c82ac3b9b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -727,6 +727,10 @@ Breeze Dark Theme Ciemny motyw Breeze Breeze light Breeze jasny Breeze Light Theme Jasny motyw Breeze + Soft dark Łagodny ciemny + Soft light Łagodny jasny + Soft Dark Theme Łagodny ciemny motyw + Soft Light Theme Łagodny jasny motyw Next Theme Następny motyw Actions Akcje Brush Settings Ustawienia pędzla diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f562d57389..199fa97345 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -713,6 +713,10 @@ Breeze Dark Theme Tema Breeze Escuro Breeze light Breeze claro Breeze Light Theme Tema Breeze Claro + Soft dark Suave escuro + Soft light Suave claro + Soft Dark Theme Tema suave escuro + Soft Light Theme Tema suave claro Next Theme Próximo tema Actions Ações Brush Settings Configurações do pincel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e1fc3c399..dd7001bc1f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1433,6 +1433,10 @@ Breeze Dark Theme Тёмная тема Breeze Breeze light Breeze светлая Breeze Light Theme Светлая тема Breeze + Soft dark Мягкая тёмная + Soft light Мягкая светлая + Soft Dark Theme Мягкая тёмная тема + Soft Light Theme Мягкая светлая тема Studio Theme Тема Studio Studio Light Theme Светлая тема Studio Adwaita Light Theme Светлая тема Adwaita diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c34e673367..605ffd26f9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -702,6 +702,10 @@ Breeze Dark Theme Esinti Koyu Tema Breeze light Esinti ışığı Breeze Light Theme Esinti Işık Teması + Soft dark Yumuşak koyu + Soft light Yumuşak açık + Soft Dark Theme Yumuşak koyu tema + Soft Light Theme Yumuşak açık tema Adwaita Light Theme Adwaita Işık Teması Adwaita Dark Theme Adwaita Karanlık Tema Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 87562df818..712766a793 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1412,6 +1412,10 @@ Breeze Dark Theme Темна тема Breeze Breeze light Breeze світла Breeze Light Theme Світла тема Breeze + Soft dark М'яка темна + Soft light М'яка світла + Soft Dark Theme М'яка темна тема + Soft Light Theme М'яка світла тема Adwaita Light Theme Світла тема Adwaita Adwaita Dark Theme Темна тема Adwaita Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9fc122a766..7ef6f16dd6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2353,6 +2353,10 @@ shape Appearance Diện mạo Bottom: Dưới: Breeze Dark Theme Chủ đề Breeze tối Breeze Light Theme Chủ đề Breeze sáng + Soft dark Dịu tối + Soft light Dịu sáng + Soft Dark Theme Giao diện dịu tối + Soft Light Theme Giao diện dịu sáng Caps Đầu nét Center Columns Căn giữa các cột Change Panel Group Đổi nhóm bảng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4fb92c2ebd..142718f393 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -730,6 +730,10 @@ Breeze Dark Theme Breeze 深色主题 Breeze light Breeze 浅色 Breeze Light Theme Breeze 浅色主题 + Soft dark 柔和深色 + Soft light 柔和浅色 + Soft Dark Theme 柔和深色主题 + Soft Light Theme 柔和浅色主题 Next Theme 下一代主题 Actions 动作 Brush Settings 画笔设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3010b7edf..8e4da035be 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -735,6 +735,10 @@ Breeze Dark Theme Breeze 深色主題 Breeze light Breeze 淺色 Breeze Light Theme Breeze 淺色主題 + Soft dark 柔和深色 + Soft light 柔和淺色 + Soft Dark Theme 柔和深色主題 + Soft Light Theme 柔和淺色主題 Next Theme 下一個主題 Actions 動作 Brush Settings 筆刷設定 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 71fab42239..ee59da8e70 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,6 +89,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), @@ -374,7 +376,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: | "window.theme.adwaita" | "window.theme.adwaitaDark" | "window.theme.breezeLight" - | "window.theme.breezeDark" => { + | "window.theme.breezeDark" + | "window.theme.softLight" + | "window.theme.softDark" => { let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default(); app.set_theme(ctx, k); Ok(Value::Null) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 8260fed722..cf0ff51969 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -97,6 +97,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> LightTheme::Classic => ThemeKind::Classic, LightTheme::Adwaita => ThemeKind::Adwaita, LightTheme::BreezeLight => ThemeKind::BreezeLight, + LightTheme::SoftLight => ThemeKind::SoftLight, } } else { match interface.dark_theme { @@ -106,6 +107,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> DarkTheme::SolarizedDark => ThemeKind::SolarizedDark, DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark, DarkTheme::BreezeDark => ThemeKind::BreezeDark, + DarkTheme::SoftDark => ThemeKind::SoftDark, } } } @@ -133,6 +135,7 @@ const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita), ("breezeLight", ThemeKind::BreezeLight), + ("softLight", ThemeKind::SoftLight), ]; const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("proMedium", ThemeKind::ProMedium), @@ -141,6 +144,7 @@ const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("solarizedDark", ThemeKind::SolarizedDark), ("adwaitaDark", ThemeKind::AdwaitaDark), ("breezeDark", ThemeKind::BreezeDark), + ("softDark", ThemeKind::SoftDark), ]; fn favorite_theme_groups(favorites: &[SavedTheme]) -> (Vec, Vec) { @@ -2089,7 +2093,12 @@ mod tests { fn breeze_themes_are_chosen_by_preference_and_fix_the_matching_mode() { let ctx = egui::Context::default(); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - for (id, kind, mode) in [("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark)] { + for (id, kind, mode) in [ + ("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), + ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark), + ("softLight", ThemeKind::SoftLight, AppearanceMode::Light), + ("softDark", ThemeKind::SoftDark, AppearanceMode::Dark), + ] { app.run("prefs.set", json!({"path": "interface.theme", "value": id})).unwrap(); tick(&mut app, &ctx); let interface = app.session.prefs().interface.clone(); @@ -2098,8 +2107,8 @@ mod tests { assert_eq!(app.ui.theme, kind, "{id}"); } // The two slots are independent: choosing the dark one left the light choice alone. - assert_eq!(app.session.prefs().interface.light_theme, LightTheme::BreezeLight); - assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::BreezeDark); + assert_eq!(app.session.prefs().interface.light_theme, LightTheme::SoftLight); + assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::SoftDark); } #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bee0104b89..9573d1056a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -48,10 +48,14 @@ pub enum ThemeKind { BreezeLight, /// An adaptation of KDE Plasma's Breeze Dark look. BreezeDark, + /// A low-contrast dark theme: muted violet-grey surfaces, softened text and a periwinkle accent. + SoftDark, + /// A low-contrast light theme: pale violet-grey surfaces, softened dark text and a periwinkle accent. + SoftLight, } impl ThemeKind { - pub const ALL: [ThemeKind; 12] = [ + pub const ALL: [ThemeKind; 14] = [ ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::ProLightGray, @@ -64,6 +68,8 @@ impl ThemeKind { ThemeKind::AdwaitaDark, ThemeKind::BreezeLight, ThemeKind::BreezeDark, + ThemeKind::SoftDark, + ThemeKind::SoftLight, ]; pub fn label(self) -> &'static str { match self { @@ -79,6 +85,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "Solarized Dark", ThemeKind::BreezeLight => "Breeze Light", ThemeKind::BreezeDark => "Breeze Dark", + ThemeKind::SoftDark => "Soft Dark", + ThemeKind::SoftLight => "Soft Light", } } /// The canonical name: `ui.set {theme}` accepts it and the Window › Theme commands are @@ -97,6 +105,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "solarizedDark", ThemeKind::BreezeLight => "breezeLight", ThemeKind::BreezeDark => "breezeDark", + ThemeKind::SoftDark => "softDark", + ThemeKind::SoftLight => "softLight", } } /// Photoshop-grammar layout (tab strips, flat panels): the four Pro brightness levels. @@ -121,6 +131,8 @@ impl ThemeKind { "solarizeddark" | "solarized" => Some(ThemeKind::SolarizedDark), "breezelight" | "breeze" | "kde" | "kdelight" | "plasma" | "plasmalight" => Some(ThemeKind::BreezeLight), "breezedark" | "kdedark" | "plasmadark" => Some(ThemeKind::BreezeDark), + "softdark" => Some(ThemeKind::SoftDark), + "softlight" => Some(ThemeKind::SoftLight), _ => None, } } @@ -602,6 +614,91 @@ impl Tokens { caption_close_text: Color32::WHITE, scrim: Color32::from_black_alpha(110), }, + // Soft Dark: our own low-contrast palette on the Studio layout. Muted violet-grey + // surfaces (never near-black), softened off-white text and a periwinkle accent; the + // text colours still hold 4.5:1 on every surface (checked by the tests). + ThemeKind::SoftDark => Tokens { + kind, + chrome: Color32::from_rgb(58, 58, 68), + canvas: Color32::from_rgb(40, 40, 48), + canvas_dot: Color32::from_rgb(40, 40, 48), + dock: Color32::from_rgb(47, 47, 56), + card: Color32::from_rgb(54, 54, 64), + card_border: Color32::from_rgb(70, 70, 82), + field: Color32::from_rgb(64, 64, 75), + field_border: Color32::from_rgb(84, 84, 98), + hover: Color32::from_rgb(68, 68, 80), + pressed: Color32::from_rgb(74, 74, 88), + text: Color32::from_rgb(238, 238, 246), + text_dim: Color32::from_rgb(212, 212, 224), + text_faint: Color32::from_rgb(190, 190, 206), + icon: Color32::from_rgb(214, 214, 226), + accent: Color32::from_rgb(138, 154, 232), + accent_soft: Color32::from_rgba_unmultiplied(138, 154, 232, 48), + accent_border: Color32::from_rgba_unmultiplied(138, 154, 232, 150), + accent_text: Color32::from_rgb(190, 200, 252), + separator: Color32::from_rgb(36, 36, 44), + shadow: Color32::from_black_alpha(110), + primary_bg: Color32::from_rgb(86, 104, 196), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(250, 160, 172), + warning: Color32::from_rgb(240, 200, 130), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(40, 40, 48), + histogram_level: 220, + caption_close: Color32::from_rgb(200, 72, 86), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(100), + }, + // Soft Light: pale violet-grey surfaces, softened dark-violet text (not black) and a + // periwinkle accent, with the same generous radii as Soft Dark. + ThemeKind::SoftLight => Tokens { + kind, + chrome: Color32::from_rgb(232, 230, 236), + canvas: Color32::from_rgb(218, 216, 224), + canvas_dot: Color32::from_rgb(218, 216, 224), + dock: Color32::from_rgb(226, 224, 231), + card: Color32::from_rgb(240, 238, 243), + card_border: Color32::from_rgb(208, 205, 215), + field: Color32::from_rgb(250, 249, 252), + field_border: Color32::from_rgb(190, 187, 201), + hover: Color32::from_rgb(228, 226, 234), + pressed: Color32::from_rgb(212, 209, 221), + text: Color32::from_rgb(44, 42, 56), + text_dim: Color32::from_rgb(62, 60, 76), + text_faint: Color32::from_rgb(80, 78, 96), + icon: Color32::from_rgb(64, 62, 80), + accent: Color32::from_rgb(110, 128, 216), + accent_soft: Color32::from_rgba_unmultiplied(110, 128, 216, 44), + accent_border: Color32::from_rgba_unmultiplied(110, 128, 216, 170), + accent_text: Color32::from_rgb(46, 58, 146), + separator: Color32::from_rgb(210, 207, 218), + shadow: Color32::from_black_alpha(40), + primary_bg: Color32::from_rgb(80, 98, 190), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(166, 44, 62), + warning: Color32::from_rgb(140, 72, 0), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(44, 42, 56), + histogram_level: 240, + caption_close: Color32::from_rgb(200, 60, 72), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(90), + }, // Ethan Schoonover's Solarized Dark palette: base03 canvas, base02 surfaces, base0 // body text and the Solarized blue accent. https://ethanschoonover.com/solarized/ // (MIT; see LICENSE-solarized.txt) @@ -714,7 +811,13 @@ impl Tokens { pub fn dark(&self) -> bool { matches!( self.kind, - ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark | ThemeKind::BreezeDark + ThemeKind::Studio + | ThemeKind::Pro + | ThemeKind::ProMedium + | ThemeKind::SolarizedDark + | ThemeKind::AdwaitaDark + | ThemeKind::BreezeDark + | ThemeKind::SoftDark ) } } @@ -1168,6 +1271,42 @@ mod tests { assert_eq!(ThemeKind::from_name("gnome dark"), Some(ThemeKind::AdwaitaDark)); } + #[test] + fn soft_themes_parse_and_hold_text_contrast() { + for (kind, alias) in [(ThemeKind::SoftDark, "soft dark"), (ThemeKind::SoftLight, "Soft_Light")] { + assert_eq!(ThemeKind::from_name(kind.id()), Some(kind)); + assert_eq!(ThemeKind::from_name(kind.label()), Some(kind)); + assert_eq!(ThemeKind::from_name(alias), Some(kind)); + assert!(ThemeKind::ALL.contains(&kind)); + let t = Tokens::for_kind(kind); + assert_eq!(t.kind, kind); + assert_eq!(t.dark(), kind == ThemeKind::SoftDark); + assert!(!t.pro && !t.bevel); + // Soft means no extremes: surfaces stay off black and white. + for c in [t.chrome, t.canvas, t.dock, t.card, t.field] { + let (hi, lo) = (c.r().max(c.g()).max(c.b()), c.r().min(c.g()).min(c.b())); + assert!(if t.dark() { (32..96).contains(&hi) } else { hi < 255 && lo > 200 }, "{kind:?}: {c:?}"); + } + let tint = |under: Color32| { + let k = f32::from(t.accent_soft.a()) / 255.0; + let ch = |over: u8, under: u8| (f32::from(over) * k + f32::from(under) * (1.0 - k)).round() as u8; + Color32::from_rgb(ch(t.accent.r(), under.r()), ch(t.accent.g(), under.g()), ch(t.accent.b(), under.b())) + }; + let surfaces = [t.chrome, t.canvas, t.dock, t.card, t.field, t.hover, t.pressed, tint(t.dock), tint(t.card)]; + for (i, surface) in surfaces.into_iter().enumerate() { + for (name, colour) in [("text", t.text), ("dim", t.text_dim), ("faint", t.text_faint), ("accent text", t.accent_text)] { + assert!(contrast(colour, surface) >= 4.5, "{kind:?}: {name} on {surface:?}"); + } + // Status colours are marks on hovered or selected surfaces (3:1), text on resting ones. + let floor = if i < 5 { 4.5 } else { 3.0 }; + assert!(contrast(t.danger, surface) >= floor && contrast(t.warning, surface) >= floor, "{kind:?}: status on {surface:?}"); + } + for k in [1.0, 0.93, 0.85] { + assert!(contrast(t.primary_text, t.primary_bg.gamma_multiply(k)) >= 4.5, "{kind:?}: primary at {k}"); + } + } + } + #[test] fn breeze_themes_parse_and_meet_text_contrast() { for (kind, aliases) in [ diff --git a/docs/scorecard.md b/docs/scorecard.md index 465b7bc7a9..80c047bb41 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [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) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [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 | @@ -192,7 +192,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | 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-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | +| 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 | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | diff --git a/docs/ui-design.md b/docs/ui-design.md index 91fac93374..4636cdb7cd 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -14,6 +14,8 @@ | Solarized Dark | Ethan Schoonover's Solarized palette: base03 canvas, base02 panels, base0 text, Solarized blue accent | | Breeze Light | An adaptation of KDE Plasma's Breeze Light on the Studio layout: cool light blue-grey surfaces a few levels from Breeze's so the app blends into a Plasma desktop, Breeze-style blue accent, 5 px radii, red close button on hover; text and status colours are darker than Breeze's for contrast | | Breeze Dark | The same for Breeze Dark: cool dark blue-grey surfaces (darkest canvas, then dock, cards, chrome and controls) near Breeze's, blue accent, 5 px radii; secondary text and status colours are lighter than Breeze's for contrast | +| Soft Dark | Low-contrast dark on the Studio layout: muted violet-grey surfaces (never near-black), softened off-white text, periwinkle accent, generous 7/10/14 px radii; text still holds 4.5:1 on every surface (`theme.rs` tests) | +| Soft Light | The light counterpart: pale violet-grey surfaces, softened dark-violet text instead of black, periwinkle accent, the same radii | Both Breeze themes are our own values close to Plasma's (not a copy of its colour schemes) and put text readability first: every text colour holds 4.5:1 on every surface and on hovered, pressed and selected states (`theme.rs` tests), so their secondary text, accent and status colours are darker (Light) or lighter (Dark) than Breeze's own, and the primary button is a deeper blue with white text. @@ -21,7 +23,7 @@ Edit → Preferences → Interface shows an Appearance Mode selector (Sync with Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system. Its checkmark follows the saved Auto mode rather than the displayed palette; choosing a manual theme selects its fixed appearance mode. -The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light and Breeze Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. +The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light, Breeze Dark, Soft Dark and Soft Light (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. ## Rules diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 54cc13876c..84f071b4a2 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -78,9 +78,9 @@ note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes e [[item]] id = "UI-217-11" target = "Soft dark and soft light themes with picker previews; outlined knobs in light themes" -status = "missing" +status = "partial" issue = 217 -note = "theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes" +note = "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" [[item]] id = "UI-217-12" From 15aac8d7b6e97939f06551857b66cb0c96920f7d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 05:11:09 +0200 Subject: [PATCH 127/223] Type: type on a path, glyphs follow the curve (#4365) (#4377) * Type: type on a path, glyphs follow the curve (#4365) Add TextShape::Path (path, start offset, flip). The text is laid out as one point-text line and each glyph is placed rigidly on the flattened path by arc length, turned to the tangent at its centre; glyphs past the end are hidden and a degenerate path falls back to straight text. New commands type.attachToPath (work path or shape layer subpath) and type.detachFromPath. TextShape is no longer Copy. .pcraft round-trips the new variant. Co-Authored-By: Claude Opus 5.5 * Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/automation/src/workspace.rs | 2 + crates/doc/src/text.rs | 9 +- crates/engine/src/type_extra_cmds.rs | 102 +++++++++++++++- crates/engine/src/type_extra_cmds/tests.rs | 30 +++++ crates/format/src/convert.rs | 4 +- crates/format/tests/roundtrip.rs | 21 ++++ crates/io/src/text_import.rs | 2 +- crates/io/tests/psd_type_bounds.rs | 2 +- crates/io/tests/psd_vertical_type.rs | 2 +- crates/text/src/layout.rs | 3 +- crates/text/src/lib.rs | 2 +- crates/text/src/psd.rs | 11 +- crates/text/src/render.rs | 49 +++++++- crates/text/src/tests.rs | 64 +++++++++- crates/text/src/warp.rs | 134 +++++++++++++++++++-- crates/ui-egui/src/type_tool.rs | 4 +- crates/ui-egui/src/type_transform.rs | 2 +- docs/scorecard.md | 4 +- scorecard/type.toml | 4 +- 19 files changed, 416 insertions(+), 35 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index eaed0e1c65..cde3a3a702 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -858,6 +858,8 @@ mod tests { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "type.attachToPath" + | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" | "layer.vectorMask.edit" diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fc586e3c2c..6f7836fa46 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -209,13 +209,18 @@ pub struct ParagraphRun { } /// Point text grows from its anchor (text-space origin = first baseline at the anchor); -/// paragraph (box) text wraps inside a rectangle. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +/// paragraph (box) text wraps inside a rectangle; path text runs along a curve. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TextShape { #[default] Point, /// Text-space pixels, before the layer transform. Box { x: f32, y: f32, width: f32, height: f32 }, + /// Type on a path: one line laid out like point text, then each glyph is placed on `path` + /// (text-space pixels, before the layer transform) by arc length and turned to follow the + /// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip` + /// puts the text on the other side of the path. + Path { path: super::vector::Subpath, start: f32, flip: bool }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index a11db9976b..1e7556b1d4 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -44,6 +44,7 @@ fn has_point_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Point => Ok(()), TextShape::Box { .. } => Err("the type layer is already paragraph text".into()), + TextShape::Path { .. } => Err("detach the type layer from its path first".into()), } } @@ -51,6 +52,7 @@ fn has_box_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Box { .. } => Ok(()), TextShape::Point => Err("the type layer is already point text".into()), + TextShape::Path { .. } => Err("the type layer is path text".into()), } } @@ -279,7 +281,7 @@ fn contours(els: &[PathEl]) -> Vec> { /// which reproduces the nonzero fill under Photoshop's per-subpath path operations. pub fn text_path(doc: &Document, t: &TextLayer) -> Path { let l = layout(doc, t); - let warp = photocraft_text::render::layout_warp(&l, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&l, t); let mut subpaths = Vec::new(); for glyph in photocraft_text::render::outlines(&l, &t.transform, warp.as_ref()) { let cs = contours(&glyph); @@ -321,6 +323,95 @@ fn convert_to_shape(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "subpaths": n})) } +// ---------- type on a path ---------- + +/// Largest anchor count of a path that type can follow (a hostile document cannot make the +/// layout walk millions of segments). +const MAX_TYPE_PATH_KNOTS: usize = 100_000; + +/// The subpath `type.attachToPath` follows, in document space: the work path (default) or a +/// shape layer's path (`"shape":id`), subpath `"subpath"` (default 0). +fn path_source(doc: &Document, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let path = match p.get("shape") { + None | Some(Value::Null) => doc.work_path.as_ref().ok_or_else(|| bad("the document has no work path; draw a path or pass \"shape\":layerId"))?, + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad("\"shape\" must be a layer id"))?; + match doc.layer(LayerId(id)).map(|l| &l.content) { + Some(LayerContent::Shape(sh)) => &sh.path, + Some(_) => return Err(bad("\"shape\" is not a shape layer")), + None => return Err(EngineError::NoLayer(LayerId(id))), + } + } + }; + let index = match p.get("subpath") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("\"subpath\" must be a non-negative index"))?, + }; + let sub = path.subpaths.get(index).ok_or_else(|| bad("the path has no such subpath"))?; + if sub.knots.len() < 2 { + return Err(bad("the subpath needs at least two anchor points")); + } + if sub.knots.len() > MAX_TYPE_PATH_KNOTS { + return Err(bad("the subpath has too many anchor points")); + } + Ok(sub.clone()) +} + +fn attach_to_path(s: &mut Session, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let start = match p.get("start") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("\"start\" must be a finite number of pixels"))? as f32, + }; + let flip = match p.get("flip") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, + }; + let id = layer_param(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + if doc.effective_locks(id).all { + return Err(EngineError::Other("the type layer is locked".into())); + } + let sub = path_source(doc, p)?; + with_text(s, p, "Type on a Path", |t, _| { + // The path lives in document space; the type layer keeps it in its own text space, so + // the type lands on the path where it is drawn. + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let knots = sub.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + let path = Subpath { closed: sub.closed, knots, op: PathOp::Combine }; + if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { + return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); + } + t.shape = TextShape::Path { path, start, flip }; + Ok(()) + })?; + Ok(json!({"layer": id.0, "start": start, "flip": flip})) +} + +fn is_path_text(s: &Session) -> std::result::Result<(), String> { + match active_text(s)?.shape { + TextShape::Path { .. } => Ok(()), + _ => Err("the type layer is not on a path".into()), + } +} + +fn detach_from_path(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + with_text(s, p, "Release Type from Path", |t, _| { + if !matches!(t.shape, TextShape::Path { .. }) { + return Err(EngineError::Other("the type layer is not on a path".into())); + } + // Point text starts where the type started on the path. + let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); + t.shape = TextShape::Point; + t.transform = then_translate(&t.transform, origin.0, origin.1); + Ok(()) + })?; + Ok(json!({"layer": id.0})) +} + // ---------- warp ---------- fn warp_text(s: &mut Session, p: &Value) -> Result { @@ -588,6 +679,15 @@ pub fn specs() -> Vec { has_format_target, warp_text ), + spec!( + "type.attachToPath", + "Type on a Path", + &[], + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + has_text, + attach_to_path + ), + spec!("type.detachFromPath", "Release Type from Path", &[], LP, is_path_text, detach_from_path), spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)), spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts( s, diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 395128a440..c0c091b10c 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -283,6 +283,36 @@ fn rasterize_type_layer() { assert!(!s.is_enabled("type.rasterizeTypeLayer")); } +#[test] +fn type_attaches_to_a_path_and_releases_it() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + assert!(s.is_enabled("type.attachToPath")); + assert!(!s.is_enabled("type.detachFromPath")); + // No work path yet, a bad subpath and bad params are errors, not panics. + assert!(s.execute("type.attachToPath", json!({})).is_err()); + let flat = ink(&s, id); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + assert!(s.execute("type.attachToPath", json!({"subpath": 5})).is_err()); + assert!(s.execute("type.attachToPath", json!({"start": "far"})).is_err()); + assert!(s.execute("type.attachToPath", json!({"shape": 9999})).is_err()); + s.execute("type.attachToPath", json!({"start": 4})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0)); + let on_path = ink(&s, id); + assert!(on_path.height() > on_path.width(), "the type runs down the vertical path: {on_path:?}"); + assert!(on_path.height() > flat.height()); + assert!(!s.is_enabled("type.convertToPointText")); + s.execute("type.detachFromPath", json!({})).unwrap(); + assert_eq!(text(&s, id).shape, TextShape::Point); + assert!(ink(&s, id).width() > ink(&s, id).height()); + assert!(s.undo()); + assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 47ea5383d7..a26219033b 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -140,7 +140,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&t.psd_raw, sink), runs: t.runs.clone(), paragraphs: t.paragraphs.clone(), - shape: t.shape, + shape: t.shape.clone(), orientation: t.orientation, antialias: t.antialias, warp: t.warp.clone(), @@ -459,7 +459,7 @@ impl Loader<'_> { psd_raw: self.opt_blob(psd_raw)?, runs: runs.clone(), paragraphs: paragraphs.clone(), - shape: *shape, + shape: shape.clone(), orientation: *orientation, antialias: *antialias, warp: warp.clone(), diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 42dbfae5c3..8b74a00c4b 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -541,6 +541,27 @@ fn text_kerning_roundtrips_and_defaults() { assert!(s.contains("\"kern\":0.0"), "kern defaults to 0"); } +/// Type on a path (#218): the path, start offset and flip survive .pcraft. +#[test] +fn path_text_shape_roundtrips() { + use photocraft_doc::text::TextShape; + use photocraft_doc::vector::Subpath; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let path = Subpath::polyline(&[(0.0, 0.0), (50.0, 20.0), (120.0, 5.5)]); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + for id in ids { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true }; + } + } + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.shape.clone()), + _ => None, + }); + assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); +} + #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9159f704dd..595c887be6 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -11,7 +11,7 @@ pub(crate) const MAX_RENDER_PIXELS: u64 = 64 * 1024 * 1024; /// Renders `t` from its model unless its ink would exceed [`MAX_RENDER_PIXELS`]. fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: f32, format: photocraft_color::PixelFormat) { let layout = eng.layout(t, dpi); - let warp = photocraft_text::render::layout_warp(&layout, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&layout, t); let ink = photocraft_text::render::ink_rect_warped(&layout, &t.transform, warp.as_ref()); if u64::from(ink.width()).saturating_mul(u64::from(ink.height())) > MAX_RENDER_PIXELS { t.cache = Some(photocraft_raster::Surface::new(photocraft_color::PixelFormat { alpha: true, ..format })); diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 95e7ddfa13..8fabe8be24 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -261,7 +261,7 @@ fn imported_type_layers_keep_their_full_bounds() { // Model: size in points at the document resolution, transform and shape as written. assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt); assert_eq!(t.transform, c.transform, "{}", c.name); - match (c.box_bounds, t.shape) { + match (c.box_bounds, t.shape.clone()) { (None, TextShape::Point) => {} (Some(b), TextShape::Box { x, y, width, height }) => { assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name) diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/psd_vertical_type.rs index e69f725da7..93cd1a8858 100644 --- a/crates/io/tests/psd_vertical_type.rs +++ b/crates/io/tests/psd_vertical_type.rs @@ -34,7 +34,7 @@ fn text(doc: &Document) -> &TextLayer { #[test] fn vertical_orientation_round_trips_through_psd() { for shape in [TextShape::Point, TextShape::Box { x: -200.0, y: 0.0, width: 220.0, height: 300.0 }] { - let doc = doc_with(shape); + let doc = doc_with(shape.clone()); let ink = text(&doc).cache.as_ref().unwrap().content_bounds(); assert!(ink.height() > ink.width(), "{shape:?}: {ink:?}"); let out = photocraft_io::export(&doc, "v.psd", &Default::default()).unwrap(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fdbb8a01f..720347d622 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -483,7 +483,8 @@ impl Layouter { let text = &t.text; let (box_rect, is_box) = match t.shape { TextShape::Box { x, y, width, height } => ((x, y, width, height), true), - TextShape::Point => ((0.0, 0.0, 0.0, 0.0), false), + // Path text is laid out as one point-text line, then bent onto the path. + TextShape::Point | TextShape::Path { .. } => ((0.0, 0.0, 0.0, 0.0), false), }; let mut prev_baseline: Option = None; let mut pending_space = 0.0f32; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7dccbdf..64db53ece1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -125,7 +125,7 @@ impl TextEngine { /// Lays out and rasterizes a text layer into document space. pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) { let l = self.layout(layer, dpi); - let warp = render::layout_warp(&l, layer.warp.as_ref()); + let warp = render::layout_deform(&l, layer); let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); (l, r) } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 27b1909510..8a915c2d04 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -244,7 +244,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { - TextShape::Point => { + TextShape::Point | TextShape::Path { .. } => { let p = arr_f(frame.get("0")); if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } } @@ -260,7 +260,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { // the frame folded into its transform and its `bounds` near the origin: applying the frame // again would move it twice. let origin = match layer.shape { - TextShape::Point => Point::new(0.0, 0.0), + TextShape::Point | TextShape::Path { .. } => Point::new(0.0, 0.0), TextShape::Box { .. } => { let b = engine_data(&t.text) .map(|e| { @@ -1183,7 +1183,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E dict.set("UseFractionalGlyphWidths", E::Bool(true)); } let (shape_type, box_bounds) = match layer.shape { - TextShape::Point => (0, None), + // PSD path type is not written yet: the layer saves as point text. + TextShape::Point | TextShape::Path { .. } => (0, None), TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); @@ -1296,8 +1297,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }, ), ); - let [l, t, r, b] = match (layer.shape, ink) { - (TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], + let [l, t, r, b] = match (&layer.shape, ink) { + (&TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], (_, Some(i)) => i, _ => [0.0; 4], }; diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index b50a63fe0a..af29dd10d6 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -10,7 +10,7 @@ use skrifa::{GlyphId, MetadataProvider}; use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; -use crate::warp::Warp; +use crate::warp::{Placement, Warp}; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; @@ -90,7 +90,19 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); + let mut glyph = glyph_xform(layout, g, face.skew_deg); + // Type on a path moves each glyph rigidly: turned to the curve at its centre. + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; let single = [(0.0, 0.0)]; @@ -107,7 +119,7 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); - match warp { + match bend { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; let mut pen = Pen::new(&mut ws, local); @@ -161,6 +173,22 @@ fn glyph_xform(layout: &TextLayout, g: &PlacedGlyph, face_skew_deg: f32) -> Xfor } } +/// The horizontal centre (text space) of a placed glyph: the pen position plus half its advance. +fn glyph_center(layout: &TextLayout, g: &PlacedGlyph, font: &skrifa::FontRef, size_px: f32, coords: &[NormalizedCoord]) -> f64 { + let hs = layout.styles.get(g.style as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + let advance = match g.orient { + GlyphOrient::Horizontal => font.glyph_metrics(Size::new(size_px), LocationRef::new(coords)).advance_width(GlyphId::new(g.id)).unwrap_or(0.0), + _ => 0.0, + }; + f64::from(g.x) + f64::from(advance * hs) / 2.0 +} + +/// The deformation of a type layer: the path it runs along, or its Warp Text settings (None +/// when neither applies). +pub fn layout_deform(layout: &TextLayout, layer: &photocraft_doc::TextLayer) -> Option { + Warp::on_path(&layer.shape).or_else(|| layout_warp(layout, layer.warp.as_ref())) +} + /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option { Warp::new(warp?, layout.bounds()?) @@ -347,8 +375,19 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); - let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() }; + let mut glyph = glyph_xform(layout, g, face.skew_deg); + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } + let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { glyphs.push(rec.out); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 832f0f0d85..2dc8905448 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -152,7 +152,7 @@ fn tabs_advance_to_the_next_default_tab_stop() { #[test] fn psd_round_trips_tabs() { for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { - let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let t = TextLayer { shape: shape.clone(), ..point("Name\tValue\n\tIndented", 20.0) }; let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); assert_eq!(back.text, t.text, "{shape:?}"); } @@ -1892,3 +1892,65 @@ fn engine_data_antialias_numbers_match_photoshop() { assert_eq!(back.antialias, mode, "{n}"); } } + +/// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs +/// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. +#[test] +fn type_on_a_path_follows_the_curve() { + use photocraft_doc::vector::{Knot, Subpath}; + let fmt = PixelFormat::RGBA8; + let mut eng = TextEngine::new(); + let ink = |eng: &mut TextEngine, t: &TextLayer| { + let (_, r) = eng.render(t, 72.0, fmt); + r.rect + }; + let straight = point("Path text", 24.0); + let flat = ink(&mut eng, &straight); + assert!(flat.width() > flat.height() * 3, "{flat:?}"); + + let mut down = straight.clone(); + down.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 400.0)]), start: 0.0, flip: false }; + let r = ink(&mut eng, &down); + assert!(r.height() > r.width() * 3, "vertical path gives tall ink: {r:?}"); + assert!(r.height() >= flat.width() - 8, "arc length is preserved: {r:?} vs {flat:?}"); + + // A half circle bends the text through a larger area than the straight line. + let k = 0.5523 * 60.0; + let arc = Subpath { + closed: false, + knots: vec![ + Knot { + anchor: photocraft_geom::Point::new(-60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(-60.0, 0.0), + out_ctrl: photocraft_geom::Point::new(-60.0, -k), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(0.0, -60.0), + in_ctrl: photocraft_geom::Point::new(-k, -60.0), + out_ctrl: photocraft_geom::Point::new(k, -60.0), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -k), + out_ctrl: photocraft_geom::Point::new(60.0, 0.0), + smooth: true, + }, + ], + ..Default::default() + }; + let mut curved = straight.clone(); + curved.shape = TextShape::Path { path: arc.clone(), start: 0.0, flip: false }; + let c = ink(&mut eng, &curved); + assert!(c.height() > flat.height() * 2, "curved ink is taller: {c:?} vs {flat:?}"); + + // A path too short for the text hides the glyphs past its end. + let mut short = straight.clone(); + short.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (30.0, 0.0)]), start: 0.0, flip: false }; + assert!(ink(&mut eng, &short).width() < flat.width() / 2); + // A degenerate path falls back to straight point text instead of failing. + let mut dot = straight.clone(); + dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; + assert_eq!(ink(&mut eng, &dot), flat); +} diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index e056bdff33..f148a18e1b 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -6,7 +6,8 @@ //! formulas), the identity at `bend = 0` and continuous in `bend`. The mapping is evaluated per //! point; the renderer subdivides long segments so straight edges bend. -use photocraft_doc::text::TextWarp; +use photocraft_doc::text::{TextShape, TextWarp}; +use photocraft_doc::vector::Subpath; use photocraft_geom::warp::{StyleWarp, WarpStyle}; /// Warp styles in Photoshop's menu order: (PSD `warpStyle` value, short id used by commands). @@ -45,10 +46,29 @@ pub fn short_style(psd: &str) -> Option<&'static str> { STYLES.iter().find(|(p, _)| *p == psd).map(|(_, short)| *short) } +/// Where a glyph goes when the layout is bent: see [`Warp::place_glyph`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + /// Not path text: bend the outline point by point with [`Warp::apply`]. + Bend, + /// Beyond either end of the path: the glyph is not drawn. + Hidden, + /// Path text: the glyph keeps its shape and is moved rigidly by this text-space map + /// (`[a, b, c, d, e, f]`, applied to text-space points). + Frame([f64; 6]), +} + /// A warp prepared for one layout: the shared style math of -/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box. -#[derive(Clone, Copy, Debug)] -pub struct Warp(StyleWarp); +/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box, +/// or a path the type runs along. +#[derive(Clone, Debug)] +pub struct Warp(Kind); + +#[derive(Clone, Debug)] +enum Kind { + Style(Box), + Path(PathFrame), +} impl Warp { /// Prepares `w` for text whose bounds are `[x0, y0, x1, y1]` (text space). `None` for @@ -56,17 +76,117 @@ impl Warp { pub fn new(w: &TextWarp, bounds: [f32; 4]) -> Option { let style = WarpStyle::parse(&w.style).filter(|s| s.is_preset() && s.psd_name() == w.style)?; StyleWarp::new(style, f64::from(w.value), f64::from(w.horizontal_distortion), f64::from(w.vertical_distortion), !w.horizontal, bounds.map(f64::from)) - .map(Warp) + .map(|s| Warp(Kind::Style(Box::new(s)))) + } + + /// The deformation of type on a path (`None` for any other shape, or a path without length). + pub fn on_path(shape: &TextShape) -> Option { + let TextShape::Path { path, start, flip } = shape else { return None }; + PathFrame::new(path, *start, *flip).map(|f| Warp(Kind::Path(f))) } /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. pub fn max_segment(&self) -> f64 { - self.0.max_segment() + match &self.0 { + Kind::Style(s) => s.max_segment(), + Kind::Path(_) => 2.0, + } } /// Maps a text-space point. pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { - self.0.apply(x, y) + match &self.0 { + Kind::Style(s) => s.apply(x, y), + Kind::Path(p) => p.apply(x, y), + } + } + + /// How to place a glyph whose horizontal centre is at text-space `cx`. + pub fn place_glyph(&self, cx: f64) -> Placement { + match &self.0 { + Kind::Style(_) => Placement::Bend, + Kind::Path(p) => p.frame(cx).map_or(Placement::Hidden, Placement::Frame), + } + } +} + +/// Most cubic segments of a type path that are followed (a hostile file cannot make this large). +const MAX_PATH_SEGMENTS: usize = 4096; + +/// A flattened path with arc lengths: text-space `x` is arc length from `start`, `y` the +/// distance to the left of the direction of travel (the side the glyph tops point to). +#[derive(Clone, Debug)] +struct PathFrame { + pts: Vec<(f64, f64)>, + /// Arc length at each point. + cum: Vec, + start: f64, +} + +impl PathFrame { + fn new(path: &Subpath, start: f32, flip: bool) -> Option { + let mut pts: Vec<(f64, f64)> = Vec::new(); + for seg in path.segments().iter().take(MAX_PATH_SEGMENTS) { + let p = seg.map(|q| (q.x, q.y)); + if p.iter().any(|q| !q.0.is_finite() || !q.1.is_finite()) { + return None; + } + let poly = (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1) + (p[2].0 - p[1].0).hypot(p[2].1 - p[1].1) + (p[3].0 - p[2].0).hypot(p[3].1 - p[2].1); + let n = ((poly / 2.0).ceil() as usize).clamp(4, 128); + if pts.is_empty() { + pts.push(p[0]); + } + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + pts.push((a * p[0].0 + b * p[1].0 + c * p[2].0 + d * p[3].0, a * p[0].1 + b * p[1].1 + c * p[2].1 + d * p[3].1)); + } + } + // Drop repeated points so every piece has a direction. + pts.dedup_by(|b, a| (b.0 - a.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9); + if flip { + pts.reverse(); + } + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + acc += (p.0 - q.0).hypot(p.1 - q.1); + } + cum.push(acc); + } + (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { pts, cum, start: f64::from(start) }) + } + + fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. + fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { + let last = self.pts.len() - 1; + let i = self.cum.partition_point(|&c| c < s).clamp(1, last); + let (a, b) = (self.pts[i - 1], self.pts[i]); + let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); + let t = ((b.0 - a.0) / len, (b.1 - a.1) / len); + let along = s - self.cum[i - 1]; + ((a.0 + t.0 * along, a.1 + t.1 * along), t) + } + + fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let (p, t) = self.at(self.start + x); + (p.0 - t.1 * y, p.1 + t.0 * y) + } + + /// The rigid map for a glyph centred at text-space `cx`; `None` outside the path. + fn frame(&self, cx: f64) -> Option<[f64; 6]> { + let s = self.start + cx; + if !(0.0..=self.length()).contains(&s) { + return None; + } + let (p, t) = self.at(s); + Some([t.0, t.1, -t.1, t.0, p.0 - t.0 * cx, p.1 - t.1 * cx]) } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 6a7c816084..3238674adf 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -184,7 +184,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) { } pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { - match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? { + match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()))? { photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)), _ => None, } @@ -1013,7 +1013,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if crate::type_transform::draw(app, painter, xf, mods) { return; } - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index d99334cba9..784162b25c 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -85,7 +85,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { return None; } let LayerContent::Text(text) = &layer.content else { return None }; - let shape = text.shape; + let shape = text.shape.clone(); let size = text.size_pt; let (layout, aff, _) = crate::type_tool::layout(app, id)?; if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() { diff --git a/docs/scorecard.md b/docs/scorecard.md index 80c047bb41..6741f981e6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -10,7 +10,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | -| [Type](#type) | 4 | 2 | 3 | | +| [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 | | [Distribution](#distribution) | 5 | 4 | 2 | | @@ -210,7 +210,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | diff --git a/scorecard/type.toml b/scorecard/type.toml index 078c956a3f..ee8a876a41 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -7,9 +7,9 @@ order = 5 [[item]] id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" -status = "missing" +status = "partial" issue = 218 -note = "doc text.rs:209 TextShape has Point and Box only" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2" From 70e182b08f7cbccfb724f7a873d396a847c92b32 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 22:13:42 -0500 Subject: [PATCH 128/223] panic_hunt: don't fuzz the print commands into a real printer (#4467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * panic_hunt: don't fuzz the print commands into a real printer file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test printed a blank page on the default printer. The hunt now skips those two ids (their params are validated like everyone else's; spooling is not what the test is for). * Guard the print spooler itself and the CI environment - print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun still renders and reports the command. Test. - CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails loudly instead of printing. Documented in docs/development.md › Environment variables. --- .github/workflows/ci.yml | 4 ++++ crates/engine/src/print_cmds.rs | 21 +++++++++++++++++++++ crates/engine/src/print_cmds/tests.rs | 18 ++++++++++++++++++ crates/engine/tests/panic_hunt.rs | 8 +++++++- docs/development.md | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094c1f3893..bec29abaf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # No test or fuzz run may print a page: the engine refuses to spool, and if something ever + # bypasses that, lp fails loudly on a printer that does not exist. + PHOTOCRAFT_NO_PRINT: "1" + LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 96dfa77fd5..6a5d71ca01 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -409,6 +409,13 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut sent = false; let mut spool = Value::Null; if send && !dry { + // The last line of defence: a test or fuzz run must never print a page (`dryRun` still + // renders and reports the command). + if printing_disabled() { + return Err(other( + "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", + )); + } spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -429,6 +436,20 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. +static PRINTING_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Refuse to spool anything from this process from now on (tests, fuzzing). The +/// `PHOTOCRAFT_NO_PRINT` environment variable does the same for a whole shell (CI). +pub fn disable_printing() { + PRINTING_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Whether spooling is blocked by [`disable_printing`] or `PHOTOCRAFT_NO_PRINT`. +pub fn printing_disabled() -> bool { + PRINTING_DISABLED.load(std::sync::atomic::Ordering::Relaxed) || std::env::var_os("PHOTOCRAFT_NO_PRINT").is_some_and(|v| !v.is_empty() && v != "0") +} + fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 988f11635a..228ade337a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -345,8 +345,26 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { .args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) .env(CHILD, dir.path()) .env("PATH", dir.path()) + // The child spools into the fake `lp` above, so a shell that blocks real printing + // (`PHOTOCRAFT_NO_PRINT`, see `disabled_printing_never_spools`) must not stop it. + .env_remove("PHOTOCRAFT_NO_PRINT") .output() .unwrap(); assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test"); } + +#[test] +fn disabled_printing_never_spools() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + super::disable_printing(); + assert!(super::printing_disabled()); + let e = s.execute("file.print", json!({})).unwrap_err().to_string(); + assert!(e.contains("printing is disabled"), "{e}"); + assert!(s.execute("file.printOneCopy", json!({})).is_err()); + // A dry run still renders and reports the command it would have run. + let r = s.execute("file.print", json!({"dryRun": true})).unwrap(); + assert_eq!(r["sent"], false); + assert!(r["command"].is_array(), "{r}"); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index b301b95389..c73980a2f2 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -9,6 +9,8 @@ use std::sync::mpsc; use std::time::Duration; fn fresh() -> Session { + // Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer. + photocraft_engine::print_cmds::disable_printing(); let mut s = Session::new(); s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); @@ -58,8 +60,12 @@ fn no_command_panics_or_hangs_on_adversarial_params() { json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}), json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}), ]; + // Commands whose success reaches outside the process: `file.print` and `file.printOneCopy` + // spool to CUPS, so a fuzz run with `{}` printed a blank page on the default printer. Their + // params are validated like everyone else's; the spooling itself is not fuzzed. + const EXTERNAL: &[&str] = &["file.print", "file.printOneCopy"]; let mut bad: Vec = Vec::new(); - for spec in command_specs() { + for spec in command_specs().iter().filter(|spec| !EXTERNAL.contains(&spec.id)) { for p in ¶ms { if let Err(why) = run_guarded(spec.id, p) { bad.push(format!("{} [{why}] <- {p}", spec.id)); diff --git a/docs/development.md b/docs/development.md index 6be2bd050b..bb0de12690 100644 --- a/docs/development.md +++ b/docs/development.md @@ -104,6 +104,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| | `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | +| `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally | | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | | `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file | From 72b499a28ff8f089fecc87963561e32ec272ec26 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:20:45 -0400 Subject: [PATCH 129/223] Tile grid overlay: gate rayon to native so the web build compiles (#4694) #4217 used rayon's par_iter in ui-egui unconditionally, but rayon is a native-only dependency there, so cargo xtask wasm failed on main. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/tile_overlay.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ui-egui/src/tile_overlay.rs b/crates/ui-egui/src/tile_overlay.rs index 03d8156dde..731ddc06b8 100644 --- a/crates/ui-egui/src/tile_overlay.rs +++ b/crates/ui-egui/src/tile_overlay.rs @@ -21,6 +21,7 @@ use std::sync::Arc; use egui::{Align2, Color32, FontId, Painter, Shape, Stroke}; use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; +#[cfg(not(target_arch = "wasm32"))] use rayon::prelude::*; use crate::PhotocraftApp; @@ -70,11 +71,18 @@ pub fn tile_infos(s: &Surface, parallel: bool) -> Vec { by_ptr.entry(ptr(t)).or_insert(t); } let distinct: Vec<(usize, &Arc)> = by_ptr.into_iter().collect(); + // rayon is a native-only dependency; the web build hashes serially. + #[cfg(not(target_arch = "wasm32"))] let hashed: Vec<(usize, u64)> = if parallel { distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect() } else { distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() }; + #[cfg(target_arch = "wasm32")] + let hashed: Vec<(usize, u64)> = { + let _ = parallel; + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; let hash_of: HashMap = hashed.into_iter().collect(); let mut count: HashMap = HashMap::new(); for (_, t) in s.tiles() { From 23daf16f821fd3703ec8443deeccdcbb16345dbb Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:24:18 +0100 Subject: [PATCH 130/223] Align tighter camera JPEG references for RAW colour fitting (#4598) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/raw/README.md | 4 ++ crates/raw/src/look.rs | 115 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index 461a0ed0b6..fcb3c1a95b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -110,6 +110,10 @@ The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 sensor plane; it removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. +For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded +90–100% centred search on training cells. It requires a strong correlation improvement and +retains the held-out colour gates; the developed crop and camera calibration are unchanged. + Without a usable fit (no preview, an unrelated, monochrome or differently shaped one, or a fit that does not clearly help) the documented neutral fallback stays: the white-balanced camera channels are treated as linear sRGB primaries, with no tone curve (plausible but flat, greyish colours; converting diff --git a/crates/raw/src/look.rs b/crates/raw/src/look.rs index 98d2c38026..77a5d38089 100644 --- a/crates/raw/src/look.rs +++ b/crates/raw/src/look.rs @@ -26,6 +26,10 @@ //! axis, the camera's rendering of white, still follows the JPEG). Cells with a //! clipped sensor sample, clipped or near-black JPEG pixels are left out of the //! fit; cells on strong edges get less weight. +//! A bounded centered alignment (90–100% of the raw frame's dimensions) can +//! account for a camera JPEG's tighter framing. It changes only the fit proxy, +//! requires correlation ≥ 0.9 and an improvement ≥ 0.05, and preserves the +//! developed crop. Alignment uses training cells; held-out cells stay out of it. //! //! The reference must show the same picture (lightness correlation ≥ 0.7 with //! the raw) in colour (≥ 2 % of the cells with chroma above 4). One cell in @@ -194,10 +198,97 @@ struct Cell { /// Fits a [`CameraLook`] so that developing `s` with `opts` (white balance, /// baseline exposure) matches the camera's `reference` rendering of it. pub fn fit_look(s: &Sensor, opts: &DevelopOptions, reference: &Reference) -> Result { - let (gw, cells) = grid(s, opts, reference)?; + let (gw, mut cells) = grid(s, opts, reference)?; + align_preview(&mut cells, gw, s.baseline_exposure); fit_cells(&cells, gw, s.baseline_exposure) } +/// Camera JPEGs can show a slightly tighter frame (lens correction, for example). +/// Align only the fit proxy, never the developed crop. Search a bounded centered +/// crop of at most 10%, and require a strong, substantially improved match. +fn align_preview(cells: &mut [Cell], w: usize, exposure: f64) { + if w == 0 || cells.len() < w || !cells.len().is_multiple_of(w) { + return; + } + let h = cells.len() / w; + let gain = 2f64.powf(if exposure.is_finite() { exposure.clamp(-10.0, 10.0) } else { 0.0 }); + let fallback = color::scale(&color::mul(&color::xyz_d50_to_prophoto(), &color::srgb_to_xyz_d50()), gain); + let step = (w / 64).max(1); + let score = |scale: f64| { + let mut pairs = Vec::new(); + for y in (0..h).step_by(step) { + for x in (0..w).step_by(step) { + if (x + 2 * y) % 3 == 0 { + continue; + } + let Some(c) = cells.get(y * w + x) else { continue }; + let Some((cam, clipped)) = aligned_camera(cells, w, h, x, y, scale) else { continue }; + if clipped || c.ref_clipped >= 0.01 || c.lum <= 0.002 || c.edge_weight <= 0.05 || cam.iter().any(|v| !v.is_finite() || *v <= 1e-4) { + continue; + } + let lum = color::apply(&PRO_TO_XYZ_Y_ROW, color::apply(&fallback, cam))[0]; + pairs.push((lum.max(0.0).sqrt(), c.lum.sqrt())); + } + } + if pairs.len() < 100 { f64::NAN } else { correlation(&pairs) } + }; + let original = score(1.0); + if !original.is_finite() { + return; + } + let (mut best, mut scale) = (original, 1.0); + for i in 0..5 { + let s = 0.9 + 0.02 * i as f64; + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + if scale < 1.0 { + let center = scale; + for i in -7..=7 { + let s = (center + 0.0025 * f64::from(i)).clamp(0.9, 1.0); + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + } + if best < 0.9 || best < original + 0.05 || scale >= 1.0 { + return; + } + let aligned: Vec<_> = (0..cells.len()).map(|i| aligned_camera(cells, w, h, i % w, i / w, scale)).collect(); + for (c, aligned) in cells.iter_mut().zip(aligned) { + if let Some((cam, clipped)) = aligned { + c.cam = cam; + c.cam_clipped = clipped; + } + } +} + +/// Bilinear interpolation on cell centers, retaining clipping information from +/// every participating cell. Coordinates remain inside the bounded fit grid. +fn aligned_camera(cells: &[Cell], w: usize, h: usize, x: usize, y: usize, scale: f64) -> Option<([f64; 3], bool)> { + let coord = |i: usize, n: usize| ((i as f64 + 0.5 - n as f64 / 2.0) * scale + n as f64 / 2.0 - 0.5).clamp(0.0, n.saturating_sub(1) as f64); + let (sx, sy) = (coord(x, w), coord(y, h)); + let (x0, y0) = (sx.floor() as usize, sy.floor() as usize); + let (fx, fy) = (sx - x0 as f64, sy - y0 as f64); + let mut cam = [0.0; 3]; + let mut clipped = false; + for (dx, dy, weight) in [(0, 0, (1.0 - fx) * (1.0 - fy)), (1, 0, fx * (1.0 - fy)), (0, 1, (1.0 - fx) * fy), (1, 1, fx * fy)] { + if weight <= 0.0 { + continue; + } + let index = y0.saturating_add(dy).min(h.saturating_sub(1)).checked_mul(w)?.checked_add(x0.saturating_add(dx).min(w.saturating_sub(1)))?; + let c = cells.get(index)?; + clipped |= c.cam_clipped; + for (out, v) in cam.iter_mut().zip(c.cam) { + *out += weight * v; + } + } + Some((cam, clipped)) +} + /// The upright fit grid: (width in cells, cells row-major). fn grid(s: &Sensor, opts: &DevelopOptions, r: &Reference) -> Result<(usize, Vec), LookRejected> { let c = s.crop; @@ -817,6 +908,28 @@ mod tests { assert_ne!(d.rgb, plain.rgb); } + #[test] + fn tighter_camera_preview_aligns_for_color_without_changing_the_raw_crop() { + let (w, h) = (480, 320); + let (s, rgb) = shot(w, h, &true_matrix()); + let (x0, y0, cw, ch) = (24, 16, 432, 288); + let mut tight = Vec::with_capacity(cw * ch * 3); + for y in y0..y0 + ch { + tight.extend_from_slice(&rgb[(y * w + x0) * 3..(y * w + x0 + cw) * 3]); + } + let reference = Reference { width: cw as u32, height: ch as u32, rgb: &tight }; + let (gw, plain) = grid(&s, &wb_opts(), &reference).unwrap(); + let before = fit_cells(&plain, gw, s.baseline_exposure); + let look = fit_look(&s, &wb_opts(), &reference).unwrap(); + assert!(look.delta_e < 3.0, "{look:?}"); + if let Ok(before) = before { + assert!(look.delta_e < before.delta_e * 0.5, "aligned {look:?}, before {before:?}"); + } + let d = crate::develop_sensor(&s, &DevelopOptions { camera_look: Some(look), ..wb_opts() }).unwrap(); + assert_eq!((d.width as usize, d.height as usize), (w, h)); + assert_eq!(s.crop, crate::Rect::new(0, 0, w, h)); + } + #[test] fn follows_the_raw_orientation() { let (w, h) = (480, 320); From 876bb8e2815f2a76e798594c4db3a4ee17f75554 Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:25:42 +0100 Subject: [PATCH 131/223] Check peak RAW development memory and bound AHD scratch (#4597) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/raw/README.md | 5 ++ crates/raw/src/demosaic.rs | 35 ++++++++-- crates/raw/src/develop.rs | 83 +++++++++++++++++++++-- crates/raw/src/par.rs | 29 ++++++++ crates/raw/src/sensor.rs | 28 +++++--- crates/raw/tests/development_limits.rs | 91 ++++++++++++++++++++++++++ 6 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 crates/raw/tests/development_limits.rs diff --git a/crates/raw/README.md b/crates/raw/README.md index fcb3c1a95b..efba4e833d 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -91,6 +91,11 @@ camera colour tables were copied. 16 bits. 5. Orientation. +Development validates public sensor/crop/CFA metadata before allocation. The working-memory +limit checks peak live development buffers instead of summing disjoint phases. AHD limits +simultaneous scratch bands only when required; normal load balancing and pixels are preserved. +The default 2 GiB limit is unchanged and excludes caller-owned sensor/document storage. + Files without colour calibration (CR2, NEF, ARW, RW2, ORF, RAF…) have no camera matrix. Every one of them carries a JPEG the camera rendered from the same sensor data, so `fit_look` estimates the camera's rendering per file: both the sensor data (white-balanced as `develop` does) and the diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 2689061b32..8b87f14a69 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -187,14 +187,14 @@ fn kernels(phase: [u8; 4], method: Demosaic, stride: usize) -> [[Vec<(isize, f32 } /// Demosaics a padded CFA plane into interleaved RGB (`w × h × 3`). -pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3) -> Vec { +pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3, ahd_jobs: usize) -> Vec { let (w, h, s) = (p.w, p.h, p.stride); let mut out = vec![0.0f32; w * h * 3]; if w == 0 || h == 0 || p.data.len() < s * (h + 2 * PAD) { return out; } if method == Demosaic::Ahd { - ahd(p, phase, to_xyz, &mut out); + ahd(p, phase, to_xyz, &mut out, ahd_jobs); return out; } let ks = kernels(phase, method, s); @@ -460,7 +460,19 @@ const AHD_BAND: usize = 32; /// Extra band rows above and below the output rows. const M: usize = 3; -fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { +/// Maximum scratch allocation of one AHD band: two green, RGB and Lab float +/// planes and two byte homogeneity planes, including their border rows. +pub(crate) fn ahd_scratch(w: u64, h: u64) -> u64 { + w.saturating_add(2 * PAD as u64).saturating_mul(h.min(AHD_BAND as u64).saturating_add(2 * M as u64)).saturating_mul(58) +} + +pub(crate) fn ahd_bands(h: usize) -> usize { + h.div_ceil(AHD_BAND) +} + +pub(crate) const AHD_TABLE_BYTES: u64 = (LABF_STEPS as u64 + 2) * 4; + +fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32], jobs: usize) { let (w, s) = (p.w, p.stride); let d = &p.data; // Colour at padded coordinates (PAD is even, so the phase is unchanged). @@ -468,7 +480,7 @@ fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { let white = [0.9642f32, 1.0, 0.8249]; let m: [[f32; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| to_xyz[i][j] as f32 / white[i])); let labf = LabF::new(); - par::chunks_mut(out, AHD_BAND * w * 3, |band, chunk| { + par::chunks_mut_limited(out, AHD_BAND * w * 3, jobs, |band, chunk| { let rows = chunk.len() / (w * 3); let n = rows + 2 * M; // Band row b ↔ padded row py(b); padded columns are used as is. @@ -577,13 +589,24 @@ mod tests { use super::*; use crate::color::IDENTITY; + #[test] + fn ahd_band_concurrency_does_not_change_pixels() { + let (w, h) = (53, 103); + let cfa: Vec = (0..w * h).map(|i| ((i * 97 + i / w * 31) % 1009) as f32 / 1009.0).collect(); + let p = Padded::from_plane(&cfa, w, h); + let one = demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, 1); + for jobs in [2, 3, 4, 128] { + assert_eq!(one, demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, jobs)); + } + } + /// Mosaics an RGB image with the given phase. fn mosaic(rgb: &[[f32; 3]], w: usize, h: usize, phase: [u8; 4]) -> Vec { (0..w * h).map(|i| rgb[i][phase[((i / w) & 1) * 2 + ((i % w) & 1)] as usize]).collect() } fn run(cfa: &[f32], w: usize, h: usize, phase: [u8; 4], m: Demosaic) -> Vec { - demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY) + demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY, par::workers()) } fn psnr(a: &[f32], b: &[[f32; 3]], w: usize, h: usize, border: usize) -> f64 { @@ -671,7 +694,7 @@ mod tests { let cfa: Vec = (0..w * h).map(|i| ((i * 7919) % 1000) as f32 / 1000.0).collect(); for phase in [[0, 1, 1, 2], [1, 2, 0, 1]] { let p = Padded::from_plane(&cfa, w, h); - let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY); + let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY, par::workers()); let ks = kernels(phase, Demosaic::Bilinear, p.stride); for y in 0..h { for x in 0..w { diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index ebe8656a39..e3ab70b0c7 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -195,16 +195,31 @@ pub(crate) fn resolve_neutral(s: &Sensor, opts: &DevelopOptions, sc: &Scale, war /// Develops sensor data. pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let c = s.crop; - if c.is_empty() || c.x + c.width > s.width || c.y + c.height > s.height || s.data.len() < s.width * s.height * s.samples { + let samples = s.width.checked_mul(s.height).and_then(|n| n.checked_mul(s.samples)); + if c.is_empty() + || c.x.checked_add(c.width).is_none_or(|x| x > s.width) + || c.y.checked_add(c.height).is_none_or(|y| y > s.height) + || samples.is_none_or(|n| s.data.len() < n) + { return Err(RawError::malformed("crop lies outside the sensor data")); } + // LinearRaw can be monochrome (one sample) or RGB (three); CFA data needs one. if s.samples != 1 && s.samples != 3 { return Err(RawError::unsupported(format!("{} samples per pixel", s.samples))); } - // Working buffers: one f32 plane plus f32 RGB plus u16 RGB per pixel, and - // a green plane for non-Bayer patterns. + if let Some(cfa) = &s.cfa + && (s.samples != 1 + || cfa.width == 0 + || cfa.height == 0 + || cfa.width > 16 + || cfa.height > 16 + || cfa.colors.len() != cfa.width * cfa.height + || cfa.colors.iter().any(|c| *c > 2)) + { + return Err(RawError::malformed("invalid sensor CFA pattern")); + } let bayer = s.cfa.as_ref().is_none_or(|cfa| cfa.is_bayer()); - opts.limits.check(c.width as u64, c.height as u64, if bayer { 4 + 12 + 6 } else { 4 + 4 + 12 + 6 })?; + let ahd_jobs = working_memory(c.width, c.height, s.cfa.is_some(), bayer, opts, par::workers())?; let mut warnings = s.warnings.clone(); let sc = Scale::new(s); @@ -304,7 +319,7 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { if bayer { let p = cfa.phase(c.x, c.y); pattern = Some(names(&p)); - rgb = demosaic(&plane, p, opts.demosaic, &to_xyz); + rgb = demosaic(&plane, p, opts.demosaic, &to_xyz, ahd_jobs); } else { let pat = Pattern::of(cfa, c.x, c.y); pattern = Some(pat.colors.chunks(pat.w.max(1)).map(names).collect::>().join("/")); @@ -400,6 +415,36 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { }) } +/// Checks the peak of the two development phases. The CFA and non-Bayer green +/// planes are freed before the u16 output is allocated; AHD scratch is bounded +/// by reducing simultaneous bands when needed. Sensor storage belongs to the +/// caller and is checked separately by the decoder. +fn working_memory(w: usize, h: usize, cfa: bool, bayer: bool, opts: &DevelopOptions, workers: usize) -> Result { + let limits = &opts.limits; + limits.check(w as u64, h as u64, 18)?; + let pixels = (w as u64).saturating_mul(h as u64); + let output = pixels.saturating_mul(18).saturating_add((GAMMA_STEPS as u64 + 2) * 4); + let mut peak = output; + let mut jobs = workers.max(1); + if cfa { + let plane = (w as u64).saturating_add(2 * PAD as u64).saturating_mul((h as u64).saturating_add(2 * PAD as u64)).saturating_mul(4); + let mut mosaic = pixels.saturating_mul(if bayer { 12 } else { 16 }).saturating_add(plane); + if bayer && opts.demosaic == Demosaic::Ahd { + mosaic = mosaic.saturating_add(crate::demosaic::AHD_TABLE_BYTES); + let scratch = crate::demosaic::ahd_scratch(w as u64, h as u64); + let available = limits.max_alloc.saturating_sub(mosaic) / scratch.max(1); + jobs = jobs.min(crate::demosaic::ahd_bands(h)).min(usize::try_from(available).unwrap_or(usize::MAX)); + // At least one band must fit, even when no parallelism is available. + mosaic = mosaic.saturating_add(scratch.saturating_mul(jobs.max(1) as u64)); + } + peak = peak.max(mosaic); + } + if peak > limits.max_alloc || usize::try_from(peak).is_err() { + return Err(RawError::LimitExceeded(format!("{peak} bytes of RAW working memory exceed {}", limits.max_alloc))); + } + Ok(jobs.max(1)) +} + /// Gain-map factors (DNG OpcodeList2 GainMap) for data row `y`, columns /// `x0..x0 + w`, sample `k`; `None` when no map touches the row. fn gain_row(s: &Sensor, y: usize, x0: usize, w: usize, k: usize) -> Option> { @@ -544,6 +589,34 @@ pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, us mod tests { use super::*; + #[test] + fn hundred_megapixel_bayer_fits_without_raising_default_limits() { + let mut opts = DevelopOptions::default(); + for method in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + opts.demosaic = method; + for workers in [1, 10, 32, 128] { + let jobs = working_memory(11648, 8736, true, true, &opts, workers).unwrap(); + assert!(jobs <= workers); + if method == Demosaic::Ahd && workers >= 32 { + assert!(jobs < workers); + } + } + } + opts.limits.max_alloc = 1 << 30; + assert!(working_memory(11648, 8736, true, true, &opts, 1).is_err()); + } + + #[test] + fn working_memory_accounts_for_padding_and_at_least_one_ahd_band() { + let mut opts = DevelopOptions { demosaic: Demosaic::Ahd, ..Default::default() }; + opts.limits.max_alloc = 100 * 100 * 22; + assert!(working_memory(100, 100, true, true, &opts, 1).is_err()); + opts.demosaic = Demosaic::Mhc; + assert!(working_memory(100, 100, true, true, &opts, 1).is_ok()); + opts.limits.max_alloc = 100 * 100 * 18; + assert!(working_memory(100, 100, false, true, &opts, 1).is_err()); + } + #[test] fn gamma_table_matches_powf() { let lut = GammaLut::new(); diff --git a/crates/raw/src/par.rs b/crates/raw/src/par.rs index a8e2c9f949..6d23fd70c4 100644 --- a/crates/raw/src/par.rs +++ b/crates/raw/src/par.rs @@ -12,6 +12,35 @@ pub(crate) fn chunks_mut(buf: &mut [T], chunk: usize, f: impl Fn(usize, buf.chunks_mut(chunk).enumerate().for_each(|(i, c)| f(i, c)); } +/// Native worker count (the browser builds run sequentially). +pub(crate) fn workers() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + rayon::current_num_threads() + } + #[cfg(target_arch = "wasm32")] + { + 1 + } +} + +/// Processes normal-sized chunks with at most `jobs` live scratch buffers. +pub(crate) fn chunks_mut_limited(buf: &mut [T], chunk: usize, jobs: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { + let chunk = chunk.max(1); + // Each worker holds one band's scratch at a time. When the normal worker + // count fits, preserve Rayon's fine-grained load balancing. + if jobs >= workers() { + chunks_mut(buf, chunk, f); + return; + } + let per_group = buf.len().div_ceil(chunk).div_ceil(jobs.max(1)).max(1); + chunks_mut(buf, chunk.saturating_mul(per_group), |group, part| { + for (local, c) in part.chunks_mut(chunk).enumerate() { + f(group * per_group + local, c); + } + }); +} + /// Maps `f` over `0..n`, keeping order. pub(crate) fn map(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index e8c65ebf59..585191b799 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -63,14 +63,22 @@ impl Cfa { /// Colour at data coordinates. #[inline] pub fn color(&self, x: usize, y: usize) -> u8 { - let cx = (x + self.width - self.origin_x % self.width) % self.width; - let cy = (y + self.height - self.origin_y % self.height) % self.height; - self.colors.get(cy * self.width + cx).copied().unwrap_or(1) + fn relative(v: usize, origin: usize, period: usize) -> Option { + if period == 0 { + return None; + } + let (v, origin) = (v % period, origin % period); + Some(if v >= origin { v - origin } else { period - (origin - v) }) + } + let Some((cx, cy)) = relative(x, self.origin_x, self.width).zip(relative(y, self.origin_y, self.height)) else { + return 1; + }; + cy.checked_mul(self.width).and_then(|i| i.checked_add(cx)).and_then(|i| self.colors.get(i)).copied().unwrap_or(1) } /// The 2×2 colours seen from data coordinates (`x0`, `y0`), row-major. pub fn phase(&self, x0: usize, y0: usize) -> [u8; 4] { - [self.color(x0, y0), self.color(x0 + 1, y0), self.color(x0, y0 + 1), self.color(x0 + 1, y0 + 1)] + [self.color(x0, y0), self.color(x0.saturating_add(1), y0), self.color(x0, y0.saturating_add(1)), self.color(x0.saturating_add(1), y0.saturating_add(1))] } /// `true` for a Bayer pattern: a 2×2 period holding one red, one blue and two @@ -79,10 +87,11 @@ impl Cfa { if self.width == 0 || self.height == 0 || self.width > 16 || self.height > 16 { return false; } - let p = self.phase(self.origin_x, self.origin_y); - let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(self.origin_x + x, self.origin_y + y) == p[(y % 2) * 2 + x % 2])); - let greens_on_diagonal = - (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && p[1] + p[2] == 2) || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && p[0] + p[3] == 2); + let (ox, oy) = (self.origin_x % self.width, self.origin_y % self.height); + let p = self.phase(ox, oy); + let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(ox + x, oy + y) == p[(y % 2) * 2 + x % 2])); + let greens_on_diagonal = (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && u16::from(p[1]) + u16::from(p[2]) == 2) + || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && u16::from(p[0]) + u16::from(p[3]) == 2); periodic && greens_on_diagonal } } @@ -110,7 +119,8 @@ impl BlackLevels { pub fn at(&self, ax: usize, ay: usize, s: usize, spp: usize) -> f32 { let r = ay % self.rows.max(1); let c = ax % self.cols.max(1); - let base = self.values.get((r * self.cols.max(1) + c) * spp + s).or_else(|| self.values.first()).copied().unwrap_or(0.0); + let index = r.checked_mul(self.cols.max(1)).and_then(|i| i.checked_add(c)).and_then(|i| i.checked_mul(spp)).and_then(|i| i.checked_add(s)); + let base = index.and_then(|i| self.values.get(i)).or_else(|| self.values.first()).copied().unwrap_or(0.0); base + self.delta_h.get(ax).copied().unwrap_or(0.0) + self.delta_v.get(ay).copied().unwrap_or(0.0) } diff --git a/crates/raw/tests/development_limits.rs b/crates/raw/tests/development_limits.rs new file mode 100644 index 0000000000..4e5e1636d3 --- /dev/null +++ b/crates/raw/tests/development_limits.rs @@ -0,0 +1,91 @@ +//! Development limits and hostile public sensor metadata. +use photocraft_raw::testgen::DngSpec; +use photocraft_raw::*; + +fn small_sensor() -> Sensor { + decode(&DngSpec::cfa(64, 48, vec![1000; 64 * 48]).build(), &Limits::default()).unwrap() +} + +#[test] +fn checks_peak_live_buffers_instead_of_summing_disjoint_phases() { + let sensor = decode(&DngSpec::cfa(128, 96, vec![1000; 128 * 96]).build(), &Limits::default()).unwrap(); + let mut opts = DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }; + opts.limits.max_alloc = 128 * 96 * 18 + (4096 + 2) * 4; + let expected = develop_sensor(&sensor, &DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }).unwrap(); + assert_eq!(develop_sensor(&sensor, &opts).unwrap().rgb, expected.rgb); + opts.limits.max_alloc -= 1; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); +} + +#[test] +fn monochrome_and_rgb_linear_raw_preserve_output_and_memory_limits() { + let (w, h) = (32, 24); + for samples in [1, 3] { + let data = (0..w * h).flat_map(|i| (0..samples).map(move |c| (i % w * 1000 + c * 5000) as u16)).collect(); + let spec = DngSpec { samples, monochrome: samples == 1, ..DngSpec::cfa(w, h, data) }; + let sensor = decode(&spec.build(), &Limits::default()).unwrap(); + assert!(sensor.cfa.is_none()); + assert_eq!(sensor.samples, samples); + let expected = develop_sensor(&sensor, &DevelopOptions::default()).unwrap(); + let mut opts = DevelopOptions::default(); + opts.limits.max_alloc = 32 * 1024; + let got = develop_sensor(&sensor, &opts).unwrap(); + assert_eq!(got.rgb, expected.rgb); + assert_eq!((got.width, got.height), (w as u32, h as u32)); + if samples == 1 { + assert!(got.rgb.as_chunks::<3>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2])); + } + // Even the encoded RGB output alone cannot fit this budget. + opts.limits.max_alloc = (expected.rgb.len() * 2 - 1) as u64; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); + } +} + +#[test] +fn invalid_public_sensor_metadata_returns_errors() { + let sensor = small_sensor(); + let mut cases = Vec::new(); + let mut s = sensor.clone(); + s.crop.x = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.width = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.samples = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().width = 0; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors.clear(); + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors[0] = 255; + cases.push(s); + let mut s = sensor.clone(); + s.cfa = None; + s.samples = 2; + s.data.resize(64 * 48 * 2, 1000); + cases.push(s); + let mut s = sensor.clone(); + s.samples = 3; + s.data.resize(64 * 48 * 3, 1000); + cases.push(s); + for s in cases { + assert!(develop_sensor(&s, &DevelopOptions::default()).is_err()); + } +} + +#[test] +fn extreme_cfa_and_black_origins_do_not_overflow() { + let mut sensor = small_sensor(); + let cfa = sensor.cfa.as_mut().unwrap(); + cfa.origin_x = usize::MAX; + cfa.origin_y = usize::MAX; + assert!(cfa.is_bayer()); + sensor.black.rows = usize::MAX; + sensor.black.cols = usize::MAX; + assert!(develop_sensor(&sensor, &DevelopOptions::default()).is_ok()); + assert_eq!(Cfa { width: 0, height: 0, colors: vec![], origin_x: 0, origin_y: 0 }.color(1, 2), 1); +} From 09db9f14247d65389f8e0712a552e386c41429e8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 05:27:49 +0200 Subject: [PATCH 132/223] Content-Aware Fill: no tinted blotch in a smooth gradient (#4708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inpaint::fit fits each channel as gain · source + bias, the gain the ratio of the patches' deviations within the level's range and the bias clamped after it. On nearly flat patches that ratio is noise, so the gain often sat at its limit; scaling about zero, it moved a bright mean further than Default's (or High's) bias could bring back, each channel by a different amount: a tinted blotch in a sky gradient (up to 14 levels at Default, 27 at High). For nearly flat patches (deviations under about 5 levels) whose means are already within the bias of each other, the gain is now kept where the bias can still match the means. Textured patches, and flat ones of clearly different colours (Content-Aware Move's Color, measured against Photoshop), are fitted as before. Fixes #4706 Co-authored-by: Brandon Thomas --- crates/algo/src/content_aware.rs | 20 +++++++++++++++++++ crates/algo/src/inpaint.rs | 33 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 5000ad2e7c..ecdb308624 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -662,6 +662,26 @@ mod tests { assert!(err < 0.12, "mean error {err}"); } + /// A vertical sky gradient (each row one colour) filled at every adaptation level stays the + /// gradient: no tinted blotch where the copied rows' colours were shifted per channel. + #[test] + fn color_adaptation_leaves_a_smooth_gradient_untinted() { + let (w, h) = (120, 60); + let row = |y: usize| { + let t = y as f32 / (h - 1) as f32; + [0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t] + }; + let img: Vec = (0..w * h).flat_map(|i| row(i / w)).collect(); + let hole: Vec = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + let src = vec![true; w * h]; + for level in ["none", "default", "high", "veryHigh"] { + let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() }); + let (out, img) = (&out, &img); + let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max); + assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0); + } + } + /// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied. fn noise(w: usize, h: usize, seed: u64) -> Vec { let mut z = seed; diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 37e91da495..d7d43c032f 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -508,6 +508,9 @@ struct Solver<'a> { ctl: photocraft_raster::Interrupt<'a>, } +/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255). +const FLAT: f32 = 0.02; + /// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)` /// over `n` samples, within the limits. #[inline] @@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3 let vt = (stt / n - mt * mt).max(0.0); let vs = (sss / n - ms * ms).max(0.0); let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 }; - let g = g.clamp(a.gain.0, a.gain.1); + let (mut lo, mut hi) = a.gain; + // Nearly flat patches of about the same colour (their means within the bias of each other): + // the ratio of their deviations is noise, so match the mean first, keeping the gain where the + // bias can still bring the source's mean to the target's. Gained about zero, a gain at its + // limit moved bright means further than the bias could bring back, and each channel by a + // different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones + // whose colours differ more (Content-Aware Move's Color), are fitted as before. + if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 { + let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms); + if glo.max(lo) <= ghi.min(hi) { + (lo, hi) = (glo.max(lo), ghi.min(hi)); + } + } + let g = g.clamp(lo, hi); let b = (mt - g * ms).clamp(-a.bias, a.bias); (g, b) } @@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve mod tests { use super::*; + /// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the + /// gain stays where the bias can still reach the target's mean. + #[test] + fn fit_matches_the_mean_before_the_contrast() { + let a = Adapt { gain: (0.9, 1.1), bias: 0.04 }; + // Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5). + let n = 25.0; + let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32); + let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n); + assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b); + // Where the mean can't be reached anyway, the limits hold as before. + let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n); + assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); From adfeab175069782a7dca7e36dc144544ed0e1c0c Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:29:26 -0500 Subject: [PATCH 133/223] Composited CMYK documents now export as CMYK TIFF and JPEG with their profile (#2220) (#4199) A CMYK document with several layers, masks or effects is flattened through the RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the document's mode and ICC profile. The composite is now converted back through the document's CMYK profile (relative colorimetric with black point compensation) inside each parallel worker, written as four ink channels plus straight alpha at the document depth, and tagged with that profile (the built-in coated CMYK profile when the document is untagged). Invalid, non-CMYK or unusable profiles are rejected. RGB-only formats keep the direct sRGB composite, and a single plain raster layer keeps its native samples. This fixes the output mode and tagging only: the RGB roundtrip does not preserve original ink separations, black generation or CMYK-native blending. Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- book/src/formats/raster-formats.md | 9 + crates/io/src/flat.rs | 101 +++++++--- crates/io/tests/cmyk_composite_export.rs | 235 +++++++++++++++++++++++ 3 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 crates/io/tests/cmyk_composite_export.rs diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7bca7f2fc2..2e65d51550 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through +the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed +that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile; +invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains +8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency +over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use +the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink +separations, black generation or native CMYK blending, and is not a claim of Photoshop parity. + ## Implemented decode limits `DecodeOptions::default()` applies `Limits` before decoded pixel allocation: diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index bcb9422e89..1778e5d064 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -304,6 +304,10 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { + document_to_image_for(doc, warnings, true) +} + +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { let (w, h) = (doc.size.width, doc.size.height); let canvas = doc.bounds(); let fmt = doc.pixel_format(); @@ -338,16 +342,42 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Profile::parse(bytes).map_err(|e| IoError::Unsupported(format!("invalid CMYK composite profile: {e}")))?, + None => Builtin::CoatedCmyk.profile().clone(), + }; + if profile.color_space != ColorSpace::Cmyk { + return Err(IoError::Unsupported("CMYK composite requires a CMYK ICC profile".into())); + } + let srgb = Builtin::Srgb.profile(); + Transform::new(&profile, srgb, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + Transform::new(srgb, &profile, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + if icc.is_none() { + icc = Some(profile.to_bytes().to_vec()); + } + photocraft_compose::cmyk_space(doc) + } else { + None + }; let gray = fmt.mode == ColorMode::Grayscale; - if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { - // The compositor renders CMYK/Lab documents in sRGB (CMYK through the built-in - // profile), so the file is tagged sRGB. + if (fmt.mode == ColorMode::Cmyk && !cmyk) || fmt.mode == ColorMode::Lab { + // RGB-only targets use the sRGB composite directly, without reseparating CMYK. warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); } let cs = csample(fmt.sample); - let colors = if gray { 1 } else { 3 }; + let colors = if cmyk { + 4 + } else if gray { + 1 + } else { + 3 + }; // Rendered and quantised in bands (no full-size float composite), with alpha; the alpha // is dropped afterwards, in place, when every pixel turned out opaque. let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; @@ -355,26 +385,35 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Result<(), ()> { opaque &= band.px.iter().all(|p| p[3] >= 1.0); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut put = |v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let mut converted = [0.0; 5]; + for p in &band.px[range] { + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(*value); + } + continue; + } else if gray { + put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(p[0]); + put(p[1]); + put(p[2]); + } + put(p[3]); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); - } - out + out + }) }); for part in parts { data.extend_from_slice(&part); @@ -384,7 +423,16 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Document { + let mut doc = Document::new("CMYK composite", Size::new(8, 8), ColorMode::Cmyk, depth); + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + let surface = Surface::with_default(doc.pixel_format(), &[0.3, 0.6, 0.1, 0.2, alpha]); + doc.layers.push(Layer::new("Ink", LayerContent::Raster(surface))); + doc.layers.push(Layer::new("Empty", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc +} + +fn tag(bytes: &[u8], id: u16) -> Option { + assert_eq!(&bytes[..2], b"II"); + let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); + let count = usize::from(u16::from_le_bytes(bytes[start..start + 2].try_into().unwrap())); + bytes[start + 2..] + .as_chunks::<12>() + .0 + .iter() + .take(count) + .find_map(|entry| (u16::from_le_bytes(entry[..2].try_into().unwrap()) == id).then(|| u16::from_le_bytes(entry[8..10].try_into().unwrap()))) +} + +#[test] +fn tiff_keeps_cmyk_profile_depth_and_straight_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for alpha in [1.0, 0.5] { + let doc = document(depth, alpha); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), if alpha == 1.0 { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(if alpha == 1.0 { 4 } else { 5 })); + assert_eq!(tag(&result.bytes, 338), if alpha == 1.0 { None } else { Some(2) }); + if alpha < 1.0 { + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - alpha).abs() < 0.004)); + let opaque = document_to_image(&document(depth, 1.0), &mut Vec::new()).unwrap().to_normalized(); + for (px, color) in image.to_normalized().as_chunks::<5>().0.iter().zip(opaque.as_chunks::<4>().0) { + assert!(px[..4].iter().zip(color).all(|(a, b)| (a - b).abs() < 0.004)); + } + } + } + } +} + +#[test] +fn tiff_keeps_true_zero_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + let doc = document(depth, 0.0); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(5)); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| px[4].abs() < f32::EPSILON)); + } +} + +#[test] +fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(depth, 0.5); + + let result = export(&doc, "psd", &ExportOptions::default()).unwrap(); + + let back = photocraft_io::import("composite.psd", &result.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Cmyk); + assert_eq!(back.depth, depth); + assert_eq!(back.icc_profile, doc.icc_profile); + assert_eq!(back.layer_count(), doc.layer_count()); + } +} + +#[test] +fn jpeg_keeps_four_components_profile_and_zero_ink_matte_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5, 0.0] { + let doc = document(depth, alpha); + let mut opts = ExportOptions::default(); + opts.encode.jpeg_quality = 100; + + let result = export(&doc, "jpg", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + let adobe = result.bytes.windows(5).position(|bytes| bytes == b"Adobe").unwrap(); + assert_eq!(result.bytes[adobe + 11], 0); + let mut marker = 2; + while result.bytes[marker + 1] != 192 { + let length = usize::from(u16::from_be_bytes(result.bytes[marker + 2..marker + 4].try_into().unwrap())); + marker += length + 2; + } + assert_eq!(result.bytes[marker + 9], 4); + let expected = document_to_image(&doc, &mut Vec::new()).unwrap(); + for (px, source) in image.to_normalized().as_chunks::<4>().0.iter().zip(expected.to_normalized().chunks_exact(expected.layout().channels())) { + for channel in 0..4 { + let ink = source[channel] * if expected.layout().has_alpha() { source[4] } else { 1.0 }; + assert!((px[channel] - ink).abs() < 0.015, "{px:?} vs {source:?}"); + } + } + if depth != SampleType::U8 { + assert!(result.warnings.iter().any(|warning| warning.contains("bit")), "{:?}", result.warnings); + } + } + } +} + +#[test] +fn composite_uses_distinctive_document_profile_on_every_worker() { + let params = photocraft_cms::synth::CmykParams { + description: "Export synthetic uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }; + let profile = photocraft_cms::synth::cmyk_profile(¶ms); + let mut doc = document(SampleType::F32, 0.5); + doc.size = Size::new(64, 64); + doc.icc_profile = Some(profile.to_bytes()); + let rgb = photocraft_compose::flatten(&doc); + let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap(); + let fallback = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut expected = [0.0; 4]; + transform.eval_fast(&rgb.px[0][..3], &mut expected); + let mut wrong = [0.0; 4]; + fallback.eval_fast(&rgb.px[0][..3], &mut wrong); + assert!(expected.iter().zip(wrong).any(|(a, b)| (a - b).abs() > 0.02)); + assert!(photocraft_io::pixels::bands(rgb.px.len()).len() > 1); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::CmykA); + for px in image.to_normalized().as_chunks::<5>().0 { + assert!(px[..4].iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.00001), "{px:?} vs {expected:?}"); + } + for extension in ["tiff", "jpg"] { + let encoded = export(&doc, extension, &ExportOptions::default()).unwrap(); + assert_eq!(decode(&encoded.bytes).unwrap().icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + } +} + +#[test] +fn cmyk_composite_rejects_invalid_non_cmyk_and_unusable_profiles() { + let without_tags = |prefix: &[u8]| { + let mut bytes = Builtin::CoatedCmyk.profile().to_bytes().to_vec(); + let count = usize::try_from(u32::from_be_bytes(bytes[128..132].try_into().unwrap())).unwrap(); + for entry in bytes[132..].as_chunks_mut::<12>().0.iter_mut().take(count) { + if &entry[..3] == prefix { + entry[..4].copy_from_slice(b"test"); + } + } + std::sync::Arc::new(bytes) + }; + for bytes in [std::sync::Arc::new(vec![1, 2, 3]), Builtin::Srgb.profile().to_bytes(), without_tags(b"B2A"), without_tags(b"A2B")] { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = Some(bytes); + + for extension in ["tiff", "jpg"] { + assert!(export(&doc, extension, &ExportOptions::default()).is_err(), "accepted invalid profile for {extension}"); + } + // RGB-only targets never write CMYK, so they still export (through the built-in profile). + assert!(export(&doc, "png", &ExportOptions::default()).is_ok(), "rejected invalid profile for png"); + } +} + +#[test] +fn untagged_composite_embeds_coated_cmyk() { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = None; + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.unwrap(), Builtin::CoatedCmyk.profile().to_bytes().as_slice()); +} + +#[test] +fn native_samples_are_preserved_without_composite_roundtrip() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(depth, 0.5); + doc.layers.pop(); + let LayerContent::Raster(surface) = &doc.layers[0].content else { panic!() }; + let expected = surface.to_interleaved(doc.bounds()); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.data(), expected); + } +} + +#[test] +fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { + let doc = document(SampleType::U16, 0.5); + let rgb = photocraft_compose::flatten(&doc); + let expected = + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + + let result = export(&doc, "png", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.data(), expected.data()); + assert_eq!(Profile::parse(image.icc.as_ref().unwrap()).unwrap().color_space, photocraft_cms::ColorSpace::Rgb); +} + +#[test] +fn layered_tiff_keeps_its_existing_cmyk_psd_composite_path() { + let doc = document(SampleType::U16, 0.5); + let opts = ExportOptions { tiff_layers: true, ..Default::default() }; + + let result = export(&doc, "tiff", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert!(image.meta.photoshop_layers.is_some()); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - 0.5).abs() < 0.0001)); +} From 70537058eb89c104006af78c5f24870b3c7413d3 Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:29:29 +0100 Subject: [PATCH 134/223] Open Canon CR3 camera JPEG previews with container orientation (#4595) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/io/src/raw.rs | 2 +- crates/io/tests/raw.rs | 12 +++ crates/raw/README.md | 2 +- crates/raw/src/cr3.rs | 202 ++++++++++++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 12 +++ crates/raw/src/preview.rs | 8 +- crates/raw/src/testgen.rs | 35 +++++++ crates/raw/tests/cr3.rs | 119 ++++++++++++++++++++++ 8 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 crates/raw/src/cr3.rs create mode 100644 crates/raw/tests/cr3.rs diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 26e62d25fd..008173839b 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -38,7 +38,7 @@ fn limits() -> Limits { fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { let img = codecs::decode_as_with(Format::Jpeg, jpeg, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?; let own = img.meta.exif.as_deref().map_or(1, codecs::exif_orientation); - let o = if own != 1 { own } else { codecs::exif_orientation(raw) }; + let o = if own != 1 { own } else { photocraft_raw::orientation(raw).unwrap_or_else(|| codecs::exif_orientation(raw)) }; Ok(img.oriented(o)?) } diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index 7a11f4fb03..d9e31a87c1 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -94,6 +94,18 @@ fn unsupported_raw_without_preview_is_a_clear_error() { } } +#[test] +fn cr3_opens_its_upright_camera_jpeg_with_explicit_preview_warning() { + let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![180; 32 * 20 * 3]).unwrap(); + let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let raw = photocraft_raw::testgen::cr3_preview(&jpeg, 6); + let r = import("shot.cr3", &raw).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (20, 32)); + assert_eq!(r.document.depth, SampleType::U8); + assert!(r.warnings[0].contains("8-bit, not the raw sensor data")); + assert!(r.warnings[0].contains("CR3")); +} + #[test] fn truncated_raws_do_not_panic() { let b = nef_with_preview(); diff --git a/crates/raw/README.md b/crates/raw/README.md index efba4e833d..232ef83b3b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -75,7 +75,7 @@ camera colour tables were copied. | Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | -| CR3 | Recognised, unsupported (preview fallback where a preview is found) | +| 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 | ## Development pipeline diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs new file mode 100644 index 0000000000..bb2119e7d4 --- /dev/null +++ b/crates/raw/src/cr3.rs @@ -0,0 +1,202 @@ +//! Bounded CR3 container reading, independently implemented from ISO BMFF and +//! Laurent Clévy's public format description (canon_cr3/readme.md). This extracts +//! camera JPEGs and orientation, not CRX sensor data. Never scan compressed data +//! for JPEG signatures: offsets must come from a validated sample table or PRVW. + +use crate::preview::{Preview, candidate}; +use crate::tiff::{Tiff, tag}; + +const META_UUID: &[u8] = &[0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const PREVIEW_UUID: &[u8] = &[0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; +const MAX_BOXES: usize = 8192; +const MAX_DEPTH: usize = 12; + +fn u32_at(b: &[u8], p: usize) -> Option { + Some(u32::from_be_bytes(b.get(p..p.checked_add(4)?)?.try_into().ok()?)) +} + +fn u64_at(b: &[u8], p: usize) -> Option { + Some(u64::from_be_bytes(b.get(p..p.checked_add(8)?)?.try_into().ok()?)) +} + +struct BoxRef<'a> { + kind: &'a [u8], + body: &'a [u8], +} + +/// Walks only complete sibling boxes, including extended lengths and a final +/// zero-length box. Invalid lengths end this container; siblings never escape it. +fn boxes<'a>(mut b: &'a [u8], budget: &mut usize, mut f: impl FnMut(BoxRef<'a>, &mut usize)) { + while b.len() >= 8 && *budget > 0 { + *budget -= 1; + let Some(n) = u32_at(b, 0) else { + return; + }; + let (size, header) = match n { + 0 => (b.len(), 8), + 1 => { + let Some(size) = u64_at(b, 8).and_then(|n| usize::try_from(n).ok()) else { + return; + }; + (size, 16) + } + n => (n as usize, 8), + }; + if size < header { + return; + } + let Some(body) = b.get(header..size) else { + return; + }; + let Some(kind) = b.get(4..8) else { + return; + }; + f(BoxRef { kind, body }, budget); + let Some(rest) = b.get(size..) else { + return; + }; + b = rest; + } +} + +#[derive(Default)] +struct Track<'a> { + jpeg: bool, + sizes: Option<&'a [u8]>, + offsets: Option<(&'a [u8], bool)>, + mapping: bool, +} + +/// Accept only a single JPEG sample description, with CRAW's fixed sample-entry +/// header followed by an explicit JPEG codec box. +fn jpeg_description(b: &[u8], budget: &mut usize) -> bool { + if u32_at(b, 0) != Some(0) || u32_at(b, 4) != Some(1) { + return false; + } + let mut jpeg = false; + if let Some(entries) = b.get(8..) { + boxes(entries, budget, |entry, budget| { + if entry.kind == b"CRAW" + && let Some(children) = entry.body.get(82..) + { + boxes(children, budget, |codec, _| { + jpeg |= codec.kind == b"JPEG"; + }); + } + }); + } + jpeg +} + +fn track_boxes<'a>(b: &'a [u8], depth: usize, budget: &mut usize, t: &mut Track<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"mdia" | b"minf" | b"stbl" => track_boxes(item.body, depth + 1, budget, t), + b"stsd" => t.jpeg = jpeg_description(item.body, budget), + b"stsz" => t.sizes = Some(item.body), + b"co64" => t.offsets = Some((item.body, true)), + b"stco" => t.offsets = Some((item.body, false)), + // One entry, starting at chunk 1, one sample per chunk, description 1. + b"stsc" => t.mapping = item.body.len() == 20 && (0..5).all(|i| u32_at(item.body, i * 4) == Some(if i == 0 { 0 } else { 1 })), + _ => {} + }); +} + +fn track_preview<'a>(raw: &'a [u8], track: &'a [u8], budget: &mut usize, media: &[(usize, usize)]) -> Option> { + let mut t = Track::default(); + track_boxes(track, 0, budget, &mut t); + if !t.jpeg || !t.mapping { + return None; + } + let sizes = t.sizes?; + let (offsets, wide) = t.offsets?; + // Burst sequences are deliberately not paired by ordinal index: their + // sample-to-chunk mapping and picture selection need a separate implementation. + if u32_at(sizes, 0) != Some(0) || u32_at(sizes, 8) != Some(1) || u32_at(offsets, 0) != Some(0) || u32_at(offsets, 4) != Some(1) { + return None; + } + let fixed = u32_at(sizes, 4)?; + let len = if fixed == 0 { u32_at(sizes, 12)? } else { fixed }; + let off = if wide { u64_at(offsets, 8)? } else { u64::from(u32_at(offsets, 8)?) }; + let off = usize::try_from(off).ok()?; + let end = off.checked_add(len as usize)?; + if !media.iter().any(|(start, stop)| off >= *start && end <= *stop) { + return None; + } + candidate(raw, off, len as usize) +} + +#[derive(Default)] +struct Found<'a> { + previews: Vec>, + orientation: Option, + media: Vec<(usize, usize)>, +} + +fn walk<'a>(raw: &'a [u8], b: &'a [u8], depth: usize, budget: &mut usize, found: &mut Found<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"moov" => walk(raw, item.body, depth + 1, budget, found), + b"trak" => { + if found.previews.len() < 128 { + found.previews.extend(track_preview(raw, item.body, budget, &found.media)); + } + } + b"uuid" => { + if item.body.get(..16) == Some(META_UUID) + && let Some(children) = item.body.get(16..) + { + walk(raw, children, depth + 1, budget, found); + } else if item.body.get(..16) == Some(PREVIEW_UUID) + && let Some(children) = item.body.get(24..) + { + walk(raw, children, depth + 1, budget, found); + } + } + b"PRVW" => { + if found.previews.len() < 128 + && let Some(len) = u32_at(item.body, 12) + && let Some(p) = candidate(item.body, 16, len as usize) + { + found.previews.push(p); + } + } + b"CMT1" => { + if let Some(tiff) = Tiff::new(item.body) + && let Some(ifd) = tiff.ifd_at(tiff.first_ifd, 0) + && let Some(o) = tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)) + { + found.orientation = Some(o as u16); + } + } + _ => {} + }); +} + +fn read(raw: &[u8]) -> Found<'_> { + let mut found = Found::default(); + if raw.get(4..12) == Some(b"ftypcrx ") { + let mut budget = MAX_BOXES; + boxes(raw, &mut budget, |item, _| { + if item.kind == b"mdat" && found.media.len() < 128 { + let start = (item.body.as_ptr() as usize).saturating_sub(raw.as_ptr() as usize); + if let Some(end) = start.checked_add(item.body.len()) { + found.media.push((start, end)); + } + } + }); + walk(raw, raw, 0, &mut budget, &mut found); + } + found +} + +pub(crate) fn previews(raw: &[u8]) -> Vec> { + read(raw).previews +} +pub(crate) fn orientation(raw: &[u8]) -> Option { + read(raw).orientation +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d5a3e987c7..f8304b3cfa 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -46,6 +46,7 @@ mod cameras; mod color; mod cr2; +mod cr3; mod demosaic; mod develop; mod dng; @@ -85,6 +86,17 @@ use crate::tiff::{Tiff, tag}; /// Name of the output colour space of [`develop`]. pub const OUTPUT_SPACE: &str = "ProPhoto RGB (ROMM primaries, D50, gamma 1.8)"; +/// The raw container's orientation, when recorded (CR3 metadata or TIFF IFD0). +/// Useful for camera JPEGs that carry no orientation of their own. +pub fn orientation(bytes: &[u8]) -> Option { + if bytes.get(4..12) == Some(b"ftypcrx ") { + return cr3::orientation(bytes); + } + let tiff = Tiff::new(bytes)?; + let ifd = tiff.ifd_at(tiff.first_ifd, 0)?; + tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)).map(|o| o as u16) +} + /// Camera raw container families. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RawFormat { diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index ec0c041fb5..d5dd013310 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -41,13 +41,13 @@ fn dct_jpeg_size(b: &[u8]) -> Option<(u32, u32)> { None } -fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { +pub(crate) fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { let jpeg = data.get(offset..offset.checked_add(len)?)?; let (width, height) = dct_jpeg_size(jpeg)?; Some(Preview { jpeg, width, height }) } -/// The largest embedded JPEG preview of a raw file (TIFF-based raws and RAF). +/// The largest embedded JPEG preview of a raw file (TIFF-based raws, RAF and CR3). pub fn embedded_preview(bytes: &[u8]) -> Option> { embedded_previews(bytes).into_iter().max_by_key(|p| u64::from(p.width) * u64::from(p.height)) } @@ -56,7 +56,9 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { /// full-size and a smaller rendering of the same picture). pub fn embedded_previews(bytes: &[u8]) -> Vec> { let mut found: Vec = Vec::new(); - if bytes.starts_with(crate::raf::MAGIC) { + if bytes.get(4..12) == Some(b"ftypcrx ") { + found = crate::cr3::previews(bytes); + } else if bytes.starts_with(crate::raf::MAGIC) { // RAF header: big-endian JPEG offset and length at bytes 84 and 88. if let Some((off, len)) = crate::raf::preview_range(bytes) { found.extend(candidate(bytes, off, len)); diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index fdacc19828..823cd97ae6 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -7,6 +7,41 @@ // ---------------------------------------------------------------- lossless JPEG +/// A CR3 container holding one JPEG track and CMT1 orientation. It deliberately +/// contains no CRX data: used to test explicit preview fallback and BMFF bounds. +pub fn cr3_preview(jpeg: &[u8], orientation: u16) -> Vec { + fn bx(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut b = ((data.len() + 8) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(data); + b + } + fn moov(jpeg_len: usize, offset: u64, orientation: u16) -> Vec { + let mut tiff = TiffBuilder::default(); + let ifd = tiff.ifd(vec![(274, Val::Short(vec![orientation]))]); + tiff.chain = vec![ifd]; + let mut meta = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + meta.extend(bx(b"CMT1", &tiff.build())); + let mut entry = vec![0; 82]; + entry.extend(bx(b"JPEG", &[0; 4])); + let mut description = vec![0, 0, 0, 0, 0, 0, 0, 1]; + description.extend(bx(b"CRAW", &entry)); + let mut sizes = vec![0; 4]; + sizes.extend_from_slice(&(jpeg_len as u32).to_be_bytes()); + sizes.extend_from_slice(&1u32.to_be_bytes()); + let mut offsets = vec![0, 0, 0, 0, 0, 0, 0, 1]; + offsets.extend_from_slice(&offset.to_be_bytes()); + let mapping: Vec = [0u32, 1, 1, 1, 1].iter().flat_map(|n| n.to_be_bytes()).collect(); + let table = [bx(b"stsd", &description), bx(b"stsz", &sizes), bx(b"co64", &offsets), bx(b"stsc", &mapping)].concat(); + let track = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &table)))); + bx(b"moov", &[bx(b"uuid", &meta), track].concat()) + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let header = moov(jpeg.len(), 0, orientation); + let offset = ftyp.len() + header.len() + 8; + [ftyp, moov(jpeg.len(), offset as u64, orientation), bx(b"mdat", jpeg)].concat() +} + /// Huffman code lengths assigned to categories in decreasing frequency /// order. Kraft sum < 1 and no code is all ones. const LENGTHS: [u8; 17] = [2, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 8, 9, 10, 11, 12, 13]; diff --git a/crates/raw/tests/cr3.rs b/crates/raw/tests/cr3.rs new file mode 100644 index 0000000000..6907172460 --- /dev/null +++ b/crates/raw/tests/cr3.rs @@ -0,0 +1,119 @@ +//! CR3 preview extraction never mistakes encoded sensor data for a JPEG. +use photocraft_raw::testgen::cr3_preview; +use photocraft_raw::{RawFormat, embedded_preview, embedded_previews, identify, orientation}; + +fn jpeg() -> Vec { + vec![0xff, 0xd8, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 32, 1, 1, 0x11, 0, 0xff, 0xd9] +} + +fn position(b: &[u8], marker: &[u8]) -> usize { + b.windows(marker.len()).position(|w| w == marker).unwrap() +} + +#[test] +fn full_size_jpeg_and_orientation_are_read_from_their_boxes() { + let j = jpeg(); + let b = cr3_preview(&j, 6); + assert_eq!(identify(&b), Some(RawFormat::Cr3)); + let p = embedded_preview(&b).unwrap(); + assert_eq!((p.width, p.height), (32, 20)); + assert_eq!(p.jpeg, j); + assert_eq!(embedded_previews(&b).len(), 1); + assert_eq!(orientation(&b), Some(6)); +} + +#[test] +fn variable_sizes_and_32_bit_chunk_offsets_work() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"stsz") + 8; + // Add a single variable-size entry and repair ancestor lengths/offset. + let len = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].fill(0); + b.splice(at + 8..at + 8, len.to_be_bytes()); + for marker in [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"stsz"] { + let at = position(&b, marker) - 4; + let n = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].copy_from_slice(&(n + 4).to_be_bytes()); + } + let at = position(&b, b"co64"); + let off = u64::from_be_bytes(b[at + 12..at + 20].try_into().unwrap()) + 4; + b[at..at + 4].copy_from_slice(b"stco"); + b[at + 12..at + 16].copy_from_slice(&(off as u32).to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn broken_tables_and_raw_codec_tracks_are_not_used() { + let good = cr3_preview(&jpeg(), 1); + for marker in [b"stsz", b"co64", b"stsc", b"JPEG"] { + let mut b = good.clone(); + let at = position(&b, marker); + b[at..at + 4].copy_from_slice(b"xxxx"); + assert!(embedded_preview(&b).is_none()); + } + let mut b = good.clone(); + let at = position(&b, b"co64") + 12; + b[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); + let mut b = good; + let at = position(&b, b"stsz") + 12; + b[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); +} + +#[test] +fn truncations_and_corrupted_box_lengths_never_panic() { + let b = cr3_preview(&jpeg(), 8); + for n in 0..b.len() { + let _ = embedded_previews(&b[..n]); + let _ = orientation(&b[..n]); + } + for marker in [b"ftyp", b"moov", b"trak", b"mdat"] { + for len in [0u32, 1, 2, 7, u32::MAX] { + let mut corrupt = b.clone(); + let at = position(&b, marker) - 4; + corrupt[at..at + 4].copy_from_slice(&len.to_be_bytes()); + let _ = embedded_previews(&corrupt); + } + } +} + +#[test] +fn extended_mdat_length_is_supported() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"mdat") - 4; + let size = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as u64 + 8; + b[at..at + 4].copy_from_slice(&1u32.to_be_bytes()); + b.splice(at + 8..at + 8, size.to_be_bytes()); + let off = position(&b, b"co64") + 12; + let n = u64::from_be_bytes(b[off..off + 8].try_into().unwrap()) + 8; + b[off..off + 8].copy_from_slice(&n.to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn prvw_is_used_when_no_jpeg_track_exists() { + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + [(body.len() as u32 + 8).to_be_bytes().as_slice(), kind, body].concat() + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut body = vec![0; 12]; + body.extend_from_slice(&(jpeg().len() as u32).to_be_bytes()); + body.extend(jpeg()); + let mut uuid = vec![0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; + uuid.extend([0; 8]); + uuid.extend(bx(b"PRVW", &body)); + let b = [ftyp, bx(b"uuid", &uuid)].concat(); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); + assert_eq!(orientation(&b), None); +} + +#[test] +fn deeply_nested_containers_are_bounded() { + let mut b = vec![0; 8]; + for _ in 0..1000 { + b = [((b.len() + 8) as u32).to_be_bytes().as_slice(), b"moov", &b].concat(); + } + let ftyp = [24u32.to_be_bytes().as_slice(), b"ftypcrx \0\0\0\x01crx isom"].concat(); + assert!(embedded_previews(&[ftyp, b].concat()).is_empty()); +} From 964f9fb9d09c20937280df7d0dac4186d5564a44 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:35:21 +1100 Subject: [PATCH 135/223] Sample every visible layer in Content-Aware Fill by default. (#4133) * Sample every visible layer in Content-Aware Fill by default. An empty layer above the photo used to report a successful fill and stay empty, because only the active layer was sampled. The result is still written to the active layer. sampleAllLayers can be set to false to keep the old behaviour. Fixes #4026 * Sample other layers in Content-Aware Fill only when asked or the layer is empty. Reuse the Remove tool's composite, which writes the active layer's format, and leave Photoshop's Sample All Layers checkbox off unless the window has no pixels on this layer. An explicit false still reads only this layer. --------- Co-authored-by: Brandon Thomas --- crates/engine/src/edit_menu_cmds.rs | 22 ++++++++++++++-- crates/engine/src/edit_menu_cmds/tests.rs | 32 +++++++++++++++++++++++ crates/engine/src/retouch_cmds.rs | 4 +-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 734f7a1b52..f798b6e4af 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -391,6 +391,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & } let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; let fmt = surf.format(); + // Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active + // layer has no pixels in the window there is nothing to sample, so that case uses the + // visible composite (#4026). An explicit false still reads only this layer. + let sample_all = match p.get("sampleAllLayers").and_then(Value::as_bool) { + Some(v) => v, + None => surf.content_bounds().intersect(&window).is_empty(), + }; let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). let (w, h) = crate::fill_cmds::window_size(window)?; @@ -407,7 +414,18 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & ctx.progress(0.0, label); let cancel = || ctx.cancelled(); let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?; + let img = if sample_all { + if cancel() { + return Err(EngineError::Cancelled); + } + let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt); + if cancel() { + return Err(EngineError::Cancelled); + } + region.data + } else { + read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)? + }; let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), @@ -1139,7 +1157,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, can_caf, content_aware_fill ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 1bede87251..624a236200 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -218,6 +218,38 @@ fn delete_and_fill_selection_removes_the_object_in_one_step() { assert!(s.execute("edit.deleteAndFillSelection", json!({})).is_err()); } +#[test] +fn content_aware_fill_on_an_empty_layer_samples_the_composite() { + let mut s = blob_session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + assert_eq!(px(&s, 30, 20)[3], 0.0, "the new layer has no paint"); + let r = s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 0); + assert_eq!(s.active().unwrap().active_layer, Some(empty)); + assert!(px(&s, 30, 20)[3] > 0.5, "the hole is filled from the layers underneath"); + s.undo(); + let missed = s.execute("edit.contentAwareFill", json!({"sampleAllLayers": false, "colorAdaptation": "none"})).unwrap(); + assert!(missed["filled"].as_u64().unwrap() > 0); + assert!(px(&s, 30, 20)[3] < 0.05, "sampling only the empty layer leaves it empty"); +} + +#[test] +fn content_aware_fill_on_an_empty_cmyk_layer_uses_the_layer_format() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 36, "mode": "cmyk", "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 12, "width": 12, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let before = px(&s, 20, 16); + assert!(before.iter().all(|c| *c < 0.05), "the new CMYK layer is empty: {before:?}"); + s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + let v = px(&s, 20, 16); + assert_eq!(v.len(), before.len()); + // White in the layer's CMYK is near zero ink with a covered alpha, not the compositor's RGBA. + assert!(v[..4].iter().all(|c| *c < 0.15), "CMYK ink stayed low: {v:?}"); + assert!(v.last().copied().unwrap_or(0.0) > 0.5, "the hole is covered: {v:?}"); +} + #[test] fn content_aware_fill_samples_photoshops_window() { // A 50 px selection samples the 200 px square centred on it (Photoshop 25.4's overlay); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 315a8d3929..c25608bb2e 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -146,7 +146,7 @@ fn damage_json(r: Rect) -> Value { /// Which pixels Clone Stamp / Healing Brush sample. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SampleLayers { +pub(crate) enum SampleLayers { Current, CurrentAndBelow, All, @@ -163,7 +163,7 @@ fn sample_layers(p: &Value, cmd: &str) -> Result { /// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer /// and everything below it. -fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { +pub(crate) fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { let mut doc = pre.clone(); if which == SampleLayers::CurrentAndBelow && let Some(id) = id From 13a16a695b1069558c5ca58f5ff340a54993174b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:35:35 +1100 Subject: [PATCH 136/223] Resize the Liquify brush with Alt+right-drag instead of painting. (#4707) The preview treated every drag, including Alt plus the right button, as a stroke. That gesture now sets the diameter and density, and a plain left drag still paints. Fixes #4702. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/liquify_ui.rs | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 0bc4cbd782..c5a04556af 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -151,6 +151,8 @@ pub struct LiquifyDialog { zoom: f32, center: [f64; 2], hover: Option<[f64; 2]>, + /// Alt+right-drag (or Control+Alt-drag) resizing the brush: screen anchor, starting size, starting density. + brush_resize: Option<(Pos2, f32, f32)>, last_dab: f64, /// Last dab + proxy update time (ms), shown in the footer. pub dab_ms: f64, @@ -482,6 +484,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> zoom: 0.0, center, hover: None, + brush_resize: None, last_dab: 0.0, dab_ms: 0.0, preview_ms: 0.0, @@ -569,6 +572,21 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { } } +/// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). +fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { + (secondary && mods.alt) || (mods.ctrl && mods.alt && !secondary) +} + +/// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion +/// sets density, Liquify's edge falloff, over the same 200 px travel the canvas uses for hardness. +fn resized_brush(start_size: f32, start_density: f32, dx: f32, dy: f32) -> (f32, f32) { + let dx = if dx.is_finite() { dx } else { 0.0 }; + let dy = if dy.is_finite() { dy } else { 0.0 }; + let size = (start_size + dx).round().clamp(1.0, 15000.0); + let density = (start_density + dy * 100.0 / crate::brush_resize::HARDNESS_TRAVEL).clamp(0.0, 100.0); + (size, (density * 10.0).round() / 10.0) +} + /// Pointer in document coordinates (from the preview or the control channel). Alt at /// pointer-down subtracts from the freeze mask (lasso), reverses a twirl and swaps Pucker/Bloat; /// Shift adds to the mask, same as no modifier. @@ -912,9 +930,26 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { d.hover = Some(to_doc(d, area, hp)); } clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - // Mouse → strokes (Space or middle button pans). - let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()); - if pan && resp.dragged() { + // Mouse → strokes (Space or middle button pans). Alt+right-drag resizes the brush (#4702). + let (pan, secondary, mods_now) = + ui.input(|i| (i.key_down(egui::Key::Space) || i.pointer.middle_down(), i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); + let resize = d.brush_resize.is_some() || (resp.drag_started() && brush_resize_gesture(mods_now, secondary)); + if resize { + if d.brush_resize.is_none() + && let Some(p) = resp.interact_pointer_pos() + { + d.brush_resize = Some((p, d.opts.size, d.opts.density)); + } + if let (Some((anchor, size0, dens0)), Some(p)) = (d.brush_resize, resp.interact_pointer_pos()) { + let delta = p - anchor; + let (size, density) = resized_brush(size0, dens0, delta.x, delta.y); + d.opts.size = size; + d.opts.density = density; + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + d.brush_resize = None; + } + } else if pan && resp.dragged() { let dl = resp.drag_delta(); d.center = [d.center[0] - f64::from(dl.x / d.zoom), d.center[1] - f64::from(dl.y / d.zoom)]; } else if let Some(pp) = resp.interact_pointer_pos() { @@ -1020,6 +1055,18 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { mod tests { use super::*; + #[test] + fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { + assert!(brush_resize_gesture(egui::Modifiers::ALT, true)); + assert!(brush_resize_gesture(egui::Modifiers::CTRL | egui::Modifiers::ALT, false)); + assert!(!brush_resize_gesture(egui::Modifiers::ALT, false), "Alt+left stays the tool's opposite"); + assert!(!brush_resize_gesture(egui::Modifiers::NONE, true)); + let (size, density) = resized_brush(40.0, 50.0, 25.0, crate::brush_resize::HARDNESS_TRAVEL); + assert_eq!((size, density), (65.0, 100.0)); + let (size, density) = resized_brush(40.0, 50.0, -100.0, 0.0); + assert_eq!((size, density), (1.0, 50.0)); + } + fn app_with_layer() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.session.execute("file.new", json!({"width": 120, "height": 80, "depth": 8})).unwrap(); From 71968f0806074e3b030f46badf62938879c3f10e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:38:14 +0200 Subject: [PATCH 137/223] Free Transform: the live preview feathers a linked layer mask (part of #4560) (#4581) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/transform_tool.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index b2ff21d531..dafe4dfe3c 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -801,7 +801,7 @@ fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option