diff --git a/Cargo.lock b/Cargo.lock index 26920a968e..aff38ac01b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1934,6 +1934,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exacl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +dependencies = [ + "bitflags 2.13.2", + "log", + "scopeguard", + "uuid", +] + [[package]] name = "exr" version = "1.74.2" @@ -3033,7 +3045,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3695,7 +3707,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4441,7 +4453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4699,6 +4711,7 @@ dependencies = [ "photocraft-geom", "photocraft-io", "rmcp", + "rustix 1.1.5", "schemars", "serde", "serde_json", @@ -4812,6 +4825,7 @@ dependencies = [ "photocraft-vector", "rayon", "roxmltree", + "same-file", "serde", "serde_json", "tempfile", @@ -4825,6 +4839,7 @@ version = "0.10.0" dependencies = [ "blake3", "crc32fast", + "exacl", "flate2", "libc", "photocraft-codecs", @@ -4838,6 +4853,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "winsafe", ] [[package]] @@ -5481,7 +5497,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6000,7 +6016,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6067,7 +6083,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6705,7 +6721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6932,10 +6948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7335,7 +7351,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8095,7 +8111,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/contributors/people.toml b/contributors/people.toml index ff61626eef..2376f65f66 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -172,3 +172,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Byxis] +sync_github_name = true diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs index e917405083..bb6b47656c 100644 --- a/crates/algo/src/blur2.rs +++ b/crates/algo/src/blur2.rs @@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V } src.get(x, y, n - 1) } - DepthSource::LayerMask => spec.depth_map?.get(x, y, 0), + DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0), }; Some(if spec.invert { 1.0 - d } else { d }) }; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index c2ff6d2e6c..ac4b3b1fcc 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -482,7 +482,8 @@ pub enum FilterParams { distribution: Distribution, monochromatic: bool, seed: u32, - /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller. + /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document + /// coordinates), supplied by the caller. #[serde(skip)] depth_map: Option>, }, @@ -972,6 +973,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). +/// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( surface: &Surface, params: &FilterParams, @@ -1014,6 +1016,8 @@ fn apply_tiled_with_impl( ctl: &photocraft_raster::Interrupt, prepare_fast_paths: bool, ) -> Option { + // Every dispatch path needs a positive step, including Motion Blur's fallback. + let tile = tile.max(1); if ctl.cancelled() { return None; } diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs index 3a9572fb46..2349604196 100644 --- a/crates/algo/src/params_ext.rs +++ b/crates/algo/src/params_ext.rs @@ -182,6 +182,9 @@ pub enum DepthSource { None, Transparency, LayerMask, + /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the + /// caller like the layer mask. + Channel, } /// Built-in Shape Blur kernels. diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 148a0723ec..06beabebbe 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() { assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 }); } +#[test] +fn nonpositive_tile_sizes_match_one_pixel_tiles() { + let area = Rect::new(0, 0, 4, 3); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = pattern(depth, area); + for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) { + let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area)); + for tile in [0, -64, i32::MIN] { + let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area)); + assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE) + .expect("a nonpositive tile size is normalized, not rejected"); + assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none()); + } + } + assert_eq!(source.read_region(area), pattern(depth, area).read_region(area)); + } +} + #[test] fn every_filter_is_tile_independent() { let s = pattern(SampleType::U8, R); diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 3e67f1cdee..91260bd285 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -333,7 +333,12 @@ fn warp( // A source straddling the horizon draws only its front side: the inverse w at a pixel has the // sign of the forward w at the source point it samples, and the other side would show as a // mirrored copy wrapped round infinity (#4863). - let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])); + // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn + // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557). + let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64))); + let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0))); + let front = + side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]))); 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. @@ -713,6 +718,22 @@ mod tests { assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum()); } + /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across + /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the + /// quad, so its side is the one drawn, not the source's majority. + #[test] + fn a_clip_wholly_on_one_side_draws_that_side() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]]; + let h = quad_to_rect(&q, 50.0, 39.0).unwrap(); + assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side"); + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap(); + assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index fb718e5668..f5e88b3591 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -30,3 +30,7 @@ cap-std = "4" [dev-dependencies] rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } + +# Reading back an extended attribute the write path should have preserved (#7095). +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 89a760b32a..0735511f20 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -480,16 +480,23 @@ mod tests { #[test] fn an_authenticated_connection_outlives_the_authentication_deadline() { let (server, mut client) = tcp_pair(); + // The test thread writes the first line before the deadline starts. The sender writes the + // second line once the connection has authenticated and the deadline has passed. + client.write_all(b"one\n").unwrap(); + let limit = Duration::from_secs(1); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit)); + let deadline = Instant::now() + limit; + let (tx, rx) = std::sync::mpsc::channel(); let sender = std::thread::spawn(move || { - client.write_all(b"one\n").unwrap(); - std::thread::sleep(Duration::from_millis(500)); + rx.recv().unwrap(); + std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now())); client.write_all(b"two\n").unwrap(); }); - let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); let mut line = String::new(); assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "one\n"); reader.get_mut().authenticated().unwrap(); + tx.send(()).unwrap(); // Sent 300 ms after the deadline: only the idle timeout applies now. assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "two\n"); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index f73a702385..77f728ce04 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -74,6 +74,13 @@ impl AuthorizedWorkspace { /// as [`photocraft_format::atomic_write`], through the directory capability). On failure /// the previous file is untouched and the temporary file is removed. /// + /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` — + /// onto the file that takes its place (#7095), through the two open descriptors rather than + /// their paths, so nothing here reaches outside the write root to finish a write. The rest + /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission + /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this + /// path may not use. + /// /// The parent directory must already exist. `cap-std` performs path /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. @@ -85,10 +92,20 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let mut file = file.into_std(); file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + // Best effort, and only when there is a file being replaced: a save still + // succeeds for a document whose Finder tag could not come along. + if let Ok(old) = root.dir.open(&relative) { + let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + } + } // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) + photocraft_format::atomic::sync_file(&file) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -572,6 +589,32 @@ mod tests { assert!(temp_files(&inside).is_empty()); } + /// #7095: replacing a file through the automation path keeps the old file's extended + /// attributes, the same as a save through `atomic_write` does. + #[cfg(unix)] + #[test] + fn write_keeps_the_replaced_files_extended_attributes() { + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + let (inside, _, workspace) = roots("xattr"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() { + return; // a file system without extended attributes: nothing to assert + } + + workspace.write("doc.psd", b"replacement, longer").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer"); + let mut buf = [0u8; 64]; + let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped"); + assert_eq!(&buf[..len], b"tagged"); + assert!(temp_files(&inside).is_empty()); + } + #[cfg(unix)] #[test] fn write_into_read_only_folder_keeps_the_original() { diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 584ceb9d6e..dfd0cd9b90 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { } /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and -/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. +/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8( optimized: bool, icc: Option<&[u8]>, dpi: Option, + exif: Option<&[u8]>, xmp: Option<&str>, ) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); @@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8( let v = d.round().min(65535.0) as u16; enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v }); } + let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d)); + // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it. + if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { + enc.add_app_segment(1, &seg).map_err(e)?; + } if let Some(x) = xmp { // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))); + let x = crate::resolution::export_xmp(x, ppi); let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); seg.extend_from_slice(x.as_bytes()); enc.add_app_segment(1, &seg).map_err(e)?; @@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8( Ok(out) } +/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment). +pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { + crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -359,7 +371,7 @@ mod tests { let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect(); for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] { for progressive in [false, true] { - let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap(); assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}"); } } @@ -428,8 +440,8 @@ mod tests { #[test] fn progressive_jpeg_decodes() { let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect(); - let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("")).unwrap(); - let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap(); + let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("")).unwrap(); + let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap(); assert_ne!(base, prog); assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("")); // SOF2 marks a progressive frame. @@ -442,7 +454,7 @@ mod tests { fn web_jpeg_xmp_resolution_follows_the_density() { let rgb = vec![100u8; 8 * 8 * 3]; let xmp = r#""#; - let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap(); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap(); let img = crate::decode(&out).unwrap(); assert_eq!(img.meta.dpi, Some((72.0, 72.0))); assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0))); @@ -461,7 +473,7 @@ mod subsampling_tests { let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect(); for q in [30, 60, 95] { for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] { - let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap(); let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8); let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap(); assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 65dd1e16e0..fb3bc3fbec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" + # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] photocraft-ipp = { workspace = true } diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs index 5ed1c3a3b0..4b8b87616f 100644 --- a/crates/engine/examples/bench_web_palette.rs +++ b/crates/engine/examples/bench_web_palette.rs @@ -18,13 +18,13 @@ fn main() -> Result<(), Box> { 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)?; + let original = optimize(&pixels, w, rect, &settings, None, 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)?; + let result = optimize(&pixels, w, rect, &edited, None, 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/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index a32256920a..d5c688f0a6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -881,11 +881,18 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| fields_to(p, $fields, desc("null")), + to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None }, } }; } +/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's +/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such +/// a step can't be exported faithfully. +fn pixel_target(p: &Value) -> bool { + p.get("target").is_none_or(|t| t.as_str() == Some("pixels")) +} + const INTERPOLATION: &[(&str, &str)] = &[ ("Bcbc", "bicubic"), ("Blnr", "bilinear"), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 335a3cbead..c4fe00eb95 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1068,3 +1068,24 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } + +/// A filter recorded against a layer mask must not export as a plain filter that edits the +/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. +#[test] +fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { + let masked = Action { + name: "Probe".into(), + steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], + ..Default::default() + }; + assert!(export("Audit", &[&masked], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); + for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { + let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); + } +} diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..7cdb595212 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -35,15 +35,14 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; -/// several selected layers are [`multi_copy_ids`]. +/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no +/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers +/// still copy their pixels; several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 && crate::layer_copy_cmds::layered(d.doc.mode) - && d.doc - .layer(id) - .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) + && d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_))) } /// A scratch document in the source's colour, to hold copied layers. @@ -280,13 +279,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, - // A group has no pixels of its own: the clipboard pixels are what it shows. + // A group or fill layer has no pixels of its own: the clipboard pixels are what it + // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas + // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors + // the clip to the OS clipboard (an empty clip would let a stale OS image replace it). (None, Some(copies)) => { let mut shown = (**copies).clone(); for l in &mut shown.layers { l.visible = true; } - Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + let clip = lift(&merged_surface(&shown), None, canvas)?; + let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; + Clip { layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -1454,12 +1458,12 @@ mod tests { assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok()); - // An adjustment layer has no pixels of its own: Copy is only enabled while its mask is - // targeted (#2928). + // An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and + // its mask while that is targeted (#2928). s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.select", json!({"layer": a})).unwrap(); - assert!(!s.is_enabled("edit.copy"), "no pixels to copy"); + assert!(s.is_enabled("edit.copy"), "the layer itself can be copied"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied"); } @@ -1916,6 +1920,56 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy + /// takes them whole and Paste recreates them, in the same or another document, in one step. + #[test] + fn copy_paste_keeps_fill_and_adjustment_layers() { + for (depth, other) in [(8, 16), (16, 32), (32, 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") }; + let src = s.active().unwrap().active_layer.unwrap(); + assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied"); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows"); + let clip = s.clipboard.clone().unwrap(); + assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step"); + assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer"); + assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer"); + s.undo(); + assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste"); + s.redo(); + assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back"); + + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") }; + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas"); + s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background"); + } + } + + /// #2344: several adjustment layers, which show nothing alone, still copy and paste together. + #[test] + fn several_adjustment_layers_copy_together() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40])); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 5, "both are pasted"); + assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_)))); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index c5f312e2cb..f9fab33e37 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -68,7 +68,7 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { let now = st.doc.layer(layer); let was = prev.layer(layer); let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), (None, None) => true, _ => false, }; @@ -214,7 +214,9 @@ fn fade(s: &mut Session, p: &Value) -> Result { if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } - let area = before.content_bounds().union(&now.content_bounds()); + // A uniform mask can change every pixel without storing a single tile. In that case + // the content bounds are empty, but Fade must still blend across the canvas. + let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; @@ -329,7 +331,7 @@ fn can_caf(s: &Session) -> std::result::Result<(), String> { } /// Mask surface (gray) of an alpha channel by index or name. -fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { +pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { match v { Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface), Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index ecbb8a7cda..873061e462 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -57,6 +57,38 @@ fn fade_with_blend_mode_and_invalidation() { assert!(s.execute("edit.fade", json!({"mode": "nonsense"})).is_err()); } +#[test] +fn fade_uniform_layer_mask_invert_at_all_depths() { + for depth in [8, 16, 32] { + for (opacity, expected) in [(0, 1.0), (50, 0.5), (100, 0.0)] { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask_pixel = |s: &Session| { + let layer = active(s); + let mask = &layer.mask.as_ref().unwrap().surface; + mask.pixel(5, 5)[0] + }; + assert!((mask_pixel(&s) - 1.0).abs() < 0.01); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| f.mask), "depth {depth}: Fade must target the mask"); + let history_before_fade = s.active().unwrap().history.past_len(); + + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let pixel = active(&s).mask.as_ref().unwrap().surface.pixel(x, y)[0]; + assert!((pixel - expected).abs() < 0.02, "depth {depth}, opacity {opacity}, ({x}, {y}): {pixel}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history_before_fade + 1); + assert!(s.undo()); + assert!(mask_pixel(&s) < 0.01, "undo restores inverted mask"); + assert!(s.redo()); + assert!((mask_pixel(&s) - expected).abs() < 0.02, "redo restores faded mask"); + } + } +} + #[test] fn purge_commands() { let mut s = session(8); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f99aefcff0..1fb3fcf300 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -234,7 +234,12 @@ pub fn same_file(a: &str, b: &str) -> bool { } #[cfg(windows)] { - // NTFS and FAT compare names without case. + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: NTFS and FAT compare names without case. if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); } @@ -481,6 +486,15 @@ fn revert(s: &mut Session) -> Result { fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; + // A copy over the document's own file replaces it, and a flat or lossy format can't be + // undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the + // shell, after the OS has confirmed the replace. + if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) { + return Err(EngineError::BadParams { + cmd: "file.saveACopy".into(), + msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(), + }); + } let mut doc = (*d.doc).clone(); if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. @@ -1639,7 +1653,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),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"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 ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 624edfdd3f..3ab50c4a77 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -135,6 +135,44 @@ fn save_a_copy_keeps_path_and_dirty_state() { assert!(s.execute("file.saveACopy", json!({})).is_err()); } +/// A copy over the document's own file, however the path is spelt, would replace it (a lossy +/// re-encode can't be undone), so it is refused unless `overwrite` is passed (#7437). +#[test] +fn save_a_copy_refuses_the_documents_own_file() { + let dir = tmp("copy-own"); + let path = png(&dir, "own.png", 6, 4, "#ff0000"); + let bytes = std::fs::read(&path).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let link = join(&dir, "link.png"); + std::fs::hard_link(&path, &link).unwrap(); + let rev = s.active().unwrap().revision; + for p in [path.clone(), format!("{dir}/./own.png"), link] { + let r = s.execute("file.saveACopy", json!({"path": p})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("overwrite")), "{p}: {r:?}"); + assert!(s.execute("file.saveACopy", json!({"path": p, "overwrite": false})).is_err(), "{p}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{p}"); + let st = s.active().unwrap(); + assert_eq!(st.path.as_deref(), Some(path.as_str()), "{p}"); + assert_eq!(st.revision, rev, "{p}"); + assert!(st.is_dirty(), "{p}"); + } + let other = join(&dir, "other.png"); + s.execute("file.saveACopy", json!({"path": other})).unwrap(); + assert!(std::fs::metadata(&other).unwrap().len() > 0); + s.execute("file.saveACopy", json!({"path": path, "overwrite": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(s.active().unwrap().path.as_deref(), Some(path.as_str())); + assert!(s.active().unwrap().is_dirty()); + + // A document that was never saved has no file of its own: it copies anywhere, as before. + let mut fresh = session(4, 4, 8); + fresh.execute("file.saveACopy", json!({"path": path})).unwrap(); + assert_eq!(fresh.active().unwrap().path, None); +} + /// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an /// alpha channel is written as 32-bit, with the channel as the alpha. #[test] diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 221709080e..dd0ff8c803 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -292,6 +292,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let surf = match &mut l.content { LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { + // A smart filter re-renders from its params alone, without the document's + // channels, so Lens Blur's alpha-channel depth would be lost (#7400). + if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) { + return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into())); + } // Non-destructive: record the filter and re-render the smart object from its source. let sf = SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 068d37e9da..b2359ffa57 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -123,9 +123,11 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.lensBlur" => { - &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] - } + "filter.blur.lensBlur" => &[ + ("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), + ("depthMap", "none|transparency|layerMask|channel"), + ("distribution", "uniform|gaussian"), + ], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index e4f6523a19..0425e1932a 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -277,6 +277,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { depth: match s(p, "depthMap", "none") { "transparency" => DepthSource::Transparency, "layerMask" => DepthSource::LayerMask, + "channel" => DepthSource::Channel, _ => DepthSource::None, }, focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), @@ -429,8 +430,19 @@ fn map_from_document(doc: &Document, layer: Option) -> Option } /// Resolves inputs that live outside the target layer: Displace's map (`mapPath`, or -/// `mapDocument` index with optional `mapLayer` id). +/// `mapDocument` index with optional `mapLayer` id), and the alpha channel Lens Blur reads its +/// depth from (`depthChannel`, an index or a name). pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::LensBlur { depth: DepthSource::Channel, depth_map, .. } = fp { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let channel = p + .get("depthChannel") + .ok_or_else(|| EngineError::Other("Lens Blur: `depthMap` channel needs `depthChannel` (an alpha channel's index or name)".into()))?; + let surface = crate::edit_menu_cmds::channel_mask(doc, channel) + .ok_or_else(|| EngineError::Other(format!("Lens Blur: the document has no alpha channel {channel}")))?; + *depth_map = Some(Arc::new(algo::Image::read(surface, doc.bounds()))); + return Ok(()); + } let FilterParams::Displace { map, .. } = fp else { return Ok(()) }; if map.is_some() { return Ok(()); @@ -662,7 +674,7 @@ pub fn specs() -> Vec { "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], - r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask|channel","depthChannel":index|name?,"focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), cmd!( "filter.blur.shapeBlur", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 0f32c5367d..490568e87c 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -220,6 +220,51 @@ fn lens_blur_uses_the_layer_mask_as_depth() { assert_ne!(pixels(&s), before); } +/// #7400: an alpha channel is a Lens Blur depth source, by name or index, as Photoshop lists +/// the document's channels in Depth Map › Source. A missing channel is an error that changes +/// nothing, and on a Smart Object (whose smart filters re-render without the channels) it is +/// refused instead of silently blurring without depth. +#[test] +fn lens_blur_reads_an_alpha_channel_as_depth() { + let mut s = session(); + // A white channel: depth 1 everywhere. Creating it targets it; the filter is for the layer. + s.execute("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let before = pixels(&s); + for channel in [json!("Depth Map"), json!(0)] { + let p = |focal: f64| json!({"radius": 6, "depthMap": "channel", "depthChannel": channel, "focalDistance": focal}); + // Focal distance 255 puts depth 1 in focus: nothing blurs. + s.execute("filter.blur.lensBlur", p(255.0)).unwrap(); + assert_eq!(pixels(&s), before, "{channel}: in focus"); + s.execute("filter.blur.lensBlur", p(0.0)).unwrap(); + assert_ne!(pixels(&s), before, "{channel}: out of focus blurs"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s), before); + } + for bad in [ + json!({"radius": 6, "depthMap": "channel"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": "Nope"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": 7}), + ] { + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("Lens Blur"), "{bad}: {e}"); + assert_eq!(pixels(&s), before, "{bad}: nothing changed"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "{bad}: the document was replaced"); + assert_eq!(st.history.past_len(), past, "{bad}: a history step was recorded"); + } + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", json!({"radius": 6, "depthMap": "channel", "depthChannel": "Depth Map"})).unwrap_err().to_string(); + assert!(e.contains("Smart Object"), "{e}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "no smart filter was added"); + assert_eq!(st.history.past_len(), past, "no history step"); +} + #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index c5c85aec89..92bbdd7463 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -549,8 +549,10 @@ fn crop_target(p: &Value) -> Result { let side = |key: &str| -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), + // Whole-number floats (`100.0`) count too, as for the crop rectangle. Some(v) => v .as_u64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=f64::from(MAX_CROP_SIDE)).contains(f)).map(|f| f as u64)) .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) .map(Some) @@ -1418,6 +1420,14 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + #[test] + fn crop_target_accepts_whole_number_floats() { + // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. + let mut s = session(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 20, "height": 10, "targetWidth": 40.0, "targetHeight": 20.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(40, 20)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -1427,6 +1437,10 @@ mod tests { json!({"targetWidth": 0, "targetHeight": 10}), json!({"targetWidth": -5, "targetHeight": 10}), json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": -1.0, "targetHeight": 10}), + json!({"targetWidth": 0.0, "targetHeight": 10}), + json!({"targetWidth": 1e39, "targetHeight": 10}), + json!({"targetWidth": 300_001.0, "targetHeight": 10}), json!({"targetWidth": "10", "targetHeight": 10}), json!({"targetWidth": 300_001, "targetHeight": 10}), json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 776b2b4d07..0cd1f01604 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -311,6 +311,19 @@ mod tests { assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); } + /// #7557: a quad low on a strongly converging photo (its horizon crosses the canvas, with the + /// canvas centre beyond it) is cropped, not left blank in the background colour. + #[test] + fn a_quad_below_a_horizon_across_the_canvas_keeps_its_content() { + let mut doc = Document::with_background("red", Size::new(100, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute(CMD, json!({"corners": [[40, 70], [60, 70], [90, 95], [10, 95]], "width": 50, "height": 39})).unwrap(); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(25, 20); + assert_eq!(px, [1.0, 0.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e02ad0c9b..41b8d7db86 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1039,7 +1039,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", - "enhancedControls.touchGestures", "rawDefaults.colorSpace", "rawDefaults.bitDepth", "rawDefaults.resolution", diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 3cc99aa276..0a1701a166 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -654,7 +654,11 @@ fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { && page.label.as_deref().is_none_or(str::is_empty) } -/// Print One Copy repeats these. +/// Where the settings of the last print are kept in Preferences' remembered dialog settings. +pub const REMEMBERED: &str = "file.print"; + +/// Print One Copy repeats these, and Print opens with them. They are kept in the preferences as +/// well, so they survive a relaunch, as Photoshop's do (#7410). fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); if let Some(o) = remembered.as_object_mut() { @@ -663,9 +667,31 @@ fn remember_print(s: &mut Session, p: &Value) { // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); } + let kept = remembered.clone(); + s.prefs.edit(|prefs| { + prefs.dialogs.insert(REMEMBERED.into(), kept); + }); s.file_menu.last_print = Some(remembered); } +/// Drops the `printer` of the settings `q` (and its driver settings) when the print server lists +/// printers but not that one, so the job goes to the default printer. An empty list can't tell. +fn drop_vanished_printer(q: &mut Value, listed: &[&str]) { + let Some(name) = q.get("printer").and_then(Value::as_str).filter(|n| !n.is_empty()) else { return }; + if listed.is_empty() || listed.iter().any(|l| l.eq_ignore_ascii_case(name)) { + return; + } + if let Some(o) = q.as_object_mut() { + o.remove("printer"); + o.remove("printerSettings"); + } +} + +/// The settings of the last print: this session's, else those remembered from an earlier one. +pub fn last_print(s: &Session) -> Option { + s.file_menu.last_print.clone().or_else(|| s.prefs().dialogs.get(REMEMBERED).filter(|v| v.is_object()).cloned()) +} + /// The `printer` parameter, or the system's default printer. fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { @@ -788,7 +814,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } fn print_one_copy(s: &mut Session, p: &Value) -> Result { - let mut q = s.file_menu.last_print.clone().unwrap_or_else(|| json!({})); + let mut q = last_print(s).unwrap_or_else(|| json!({})); + // A printer remembered from an earlier session may be gone: then the default takes the job, + // as the Print dialog falls back (#7410). A list that can't be read decides nothing. + if let Ok(listed) = system_printers(s, &q) { + let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + drop_vanished_printer(&mut q, &names); + } if let (Some(o), Some(extra)) = (q.as_object_mut(), p.as_object()) { for (k, v) in extra { o.insert(k.clone(), v.clone()); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index ddd1a65a16..c54dddf6f8 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -226,6 +226,48 @@ fn printer_profile_can_be_an_installed_icc_file() { let _ = std::fs::remove_dir_all(&dir); } +/// #7410: the settings of the last print survive a relaunch, as Photoshop's do. They are kept in +/// the preferences (without the output, dry-run and send choices), and a new session with those +/// preferences, before printing anything, repeats them with Print One Copy. +#[test] +fn the_last_print_settings_survive_a_relaunch() { + let mut s = session("rgb", 8); + assert!(last_print(&s).is_none()); + s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50, "intent": "perceptual"})).unwrap(); + let kept = s.prefs().dialogs.get(REMEMBERED).cloned().expect("kept in the preferences"); + assert_eq!((&kept["paper"], &kept["scale"], &kept["intent"]), (&json!("a5"), &json!(50), &json!("perceptual"))); + assert!(kept.get("dryRun").is_none() && kept.get("output").is_none() && kept.get("send").is_none(), "{kept}"); + // A relaunch: a new session with these preferences, nothing printed in it yet. + let mut t = session("rgb", 8); + t.prefs.edit(|p| { + p.dialogs.insert(REMEMBERED.into(), kept.clone()); + }); + assert_eq!(last_print(&t), Some(kept)); + let r = t.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + assert_eq!(r["copies"], 1); + assert_eq!(r["paper"], json!([419.53, 595.28])); + assert_eq!(r["scale"], 50.0); +} + +/// #7410: a printer remembered from an earlier session that the print server no longer lists is +/// dropped before Print One Copy repeats the settings (with its driver settings), so the default +/// printer takes the job; a listed one (in any case) stays, and an empty list can't tell. +#[test] +fn a_vanished_remembered_printer_falls_back_to_the_default() { + let settings = || json!({"printer": "Office", "printerSettings": "00ff", "paper": "a5"}); + let mut q = settings(); + drop_vanished_printer(&mut q, &["Studio", "Lab"]); + assert_eq!(q, json!({"paper": "a5"}), "gone: the default printer takes it"); + for listed in [&["Studio", "office"][..], &[][..]] { + let mut q = settings(); + drop_vanished_printer(&mut q, listed); + assert_eq!(q, settings(), "{listed:?}"); + } + let mut q = json!({"printer": "", "paper": "a5"}); + drop_vanished_printer(&mut q, &["Studio"]); + assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); +} + #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 5854be5cbf..712cc3c89d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -504,8 +504,9 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } -/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the -/// character before it (or after it at the start). +/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its +/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting +/// at a caret takes the style of the character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); @@ -521,7 +522,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) + .position(|(s, e)| { + if a == 0 { + e > 0 || s == 0 + } else if a < b { + a >= s && a < e + } else { + a > s && a <= e + } + }) .unwrap_or(0) }; for (i, (len, st)) in runs.into_iter().enumerate() { @@ -1547,6 +1556,25 @@ mod tests { assert_eq!(t.runs[0].style.size_pt, 40.0); } + /// Replacing a selected smaller second line (by typing or pasting) keeps its size, not the + /// first line's, whose line break precedes the selection. + #[test] + fn replacing_a_selection_keeps_its_first_characters_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "BIG\nsmall", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 4, "end": 9, "size": 12}]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 9, "text": "AIR"}})).unwrap(); + let sizes = |s: &Session| text_layer(s, id).char_runs().iter().map(|r| (r.len, r.style.size_pt)).collect::>(); + assert_eq!(text_layer(&s, id).text, "BIG\nAIR"); + assert_eq!(sizes(&s), [(4, 40.0), (3, 12.0)]); + // A mixed selection takes its first character's style. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 6, "text": "xy"}})).unwrap(); + assert_eq!(sizes(&s), [(4, 40.0), (1, 12.0)]); + // Inserting at a caret still follows the character before it. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 4, "text": "!"}})).unwrap(); + assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]); + } + fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> { let t = text_layer(s, id); let mut out = Vec::new(); diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 9f0e25574d..29237fd5ad 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -70,6 +70,9 @@ pub enum WebMetadata { None, Copyright, CopyrightAndContact, + /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). + AllExceptCameraInfo, + /// All the XMP and the photo's EXIF. All, } @@ -203,8 +206,9 @@ impl WebSettings { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, - "all" | "allExceptCameraInfo" => WebMetadata::All, - m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), + "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, + "all" => WebMetadata::All, + m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { @@ -262,6 +266,7 @@ impl WebSettings { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", + WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", 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]))); @@ -387,7 +392,7 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, - WebMetadata::All => Some(xmp.to_string()), + WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); @@ -405,10 +410,33 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { } } +/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). +pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { + doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) +} + +/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). +fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { + exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) + .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) + .into_iter() + .collect() +} + /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// 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 { +pub fn optimize( + px: &[[f32; 4]], + bw: usize, + rect: Rect, + st: &WebSettings, + icc: Option<&[u8]>, + exif: Option<&[u8]>, + xmp: Option<&str>, + dpi: f32, + preview: bool, +) -> Result { 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")); @@ -460,8 +488,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) - .map_err(e)?; + let bytes = + photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -873,7 +902,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; + let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); @@ -895,18 +926,18 @@ 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)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; 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)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, 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})); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); @@ -961,7 +992,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u cells.push((piece, bg, text)); continue; } - let o = optimize(&buf.px, bw, piece, &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; let name = unique(&stem, o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; @@ -997,7 +1028,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u 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})) + Ok( + json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), + ) } // ---------- Export Preferences / Quick Export ---------- @@ -1108,19 +1141,21 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) + Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- @@ -1430,7 +1465,7 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); - let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; + let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; written.insert(key); @@ -1504,7 +1539,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,"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"="none" (a preset's own: white; formats without transparency go over white),"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}"##, + 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"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"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, warnings}"##, has_doc, save_for_web ), @@ -1522,7 +1557,7 @@ pub fn specs() -> Vec { "Quick Export", &[], None, - r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs index 1d319cb382..0c25ce0d24 100644 --- a/crates/engine/src/web_cmds/color_table_tests.rs +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -7,13 +7,13 @@ fn color_replacement_preserves_pixel_membership_and_transparency() { 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 before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, 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(); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, 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); @@ -81,8 +81,8 @@ fn malformed_tables_and_out_of_bounds_slices_return_errors() { } 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()); + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, 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()); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, 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 594fe8d496..5791e5f27c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -476,11 +476,8 @@ fn same_file_sees_through_other_names_for_one_file() { assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); - #[cfg(unix)] - { - std::fs::hard_link(&a, &b).unwrap(); - assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); - } + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); } @@ -738,7 +735,7 @@ fn previews_show_the_optimised_pixels() { let buf = photocraft_compose::flatten(&wd); for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); - let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); // Red square centre stays red (WBMP: dark). let i = (16 * 64 + 16) * 4; @@ -748,7 +745,7 @@ fn previews_show_the_optimised_pixels() { } else { assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}"); } - let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap(); + let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, false).unwrap(); assert!(none.preview.is_empty()); assert_eq!(none.bytes, o.bytes); } @@ -759,7 +756,7 @@ fn jpeg_preview_at_odd_sizes() { for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] { let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect(); let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap(); - let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, None, 72.0, true).unwrap(); for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] { let i = (y * w + x) * 4; let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32]; @@ -817,3 +814,166 @@ fn overlapping_slices_keep_the_later_slice_in_html() { #[path = "color_table_tests.rs"] mod color_table_tests; + +/// A little-endian camera EXIF: IFD0 with Make, Orientation 6, 300 ppi and pointers to an Exif +/// IFD (DateTimeOriginal) and a GPS IFD (GPSLatitudeRef); IFD0 links to an IFD1 thumbnail. +fn camera_exif() -> Vec { + let entry = |v: &mut Vec, tag: u16, ty: u16, count: u32, value: u32| { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&ty.to_le_bytes()); + v.extend_from_slice(&count.to_le_bytes()); + v.extend_from_slice(&value.to_le_bytes()); + }; + let (ifd0, data0) = (8u32, 8 + 2 + 7 * 12 + 4); + let (make, xres, yres) = (data0, data0 + 10, data0 + 18); + let exif_ifd = data0 + 26; + let date = exif_ifd + 2 + 12 + 4; + let gps_ifd = date + 20; + let ifd1 = gps_ifd + 2 + 12 + 4; + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&ifd0.to_le_bytes()); + v.extend_from_slice(&7u16.to_le_bytes()); + entry(&mut v, 0x010F, 2, 9, make); + entry(&mut v, 0x0112, 3, 1, 6); + entry(&mut v, 282, 5, 1, xres); + entry(&mut v, 283, 5, 1, yres); + entry(&mut v, 296, 3, 1, 2); + entry(&mut v, 0x8769, 4, 1, exif_ifd); + entry(&mut v, 0x8825, 4, 1, gps_ifd); + v.extend_from_slice(&ifd1.to_le_bytes()); + v.extend_from_slice(b"TestMake\0\0"); + for _ in 0..2 { + v.extend_from_slice(&300u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + } + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x9003, 2, 20, date); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"2024:05:01 10:30:00\0"); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0001, 2, 2, u32::from_le_bytes(*b"N\0\0\0")); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0103, 3, 1, 6); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), ifd1 as usize + 18); + v +} + +/// The payload (after `Exif\0\0`) of the file's EXIF APP1 segment, read from the markers. +fn exif_app1(bytes: &[u8]) -> Option> { + let mut i = 2; + while i + 4 <= bytes.len() && bytes[i] == 0xFF && bytes[i + 1] != 0xDA { + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let body = &bytes[i + 4..i + 2 + len]; + if bytes[i + 1] == 0xE1 && body.starts_with(b"Exif\0\0") { + return Some(body[6..].to_vec()); + } + i += 2 + len; + } + None +} + +/// IFD0's entries (tag → inline value) and its link to the next IFD, little-endian only. +fn ifd0(tiff: &[u8]) -> (std::collections::HashMap, u32) { + let u16_at = |o: usize| u16::from_le_bytes([tiff[o], tiff[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([tiff[o], tiff[o + 1], tiff[o + 2], tiff[o + 3]]); + assert_eq!(&tiff[..4], b"II*\0"); + let at = u32_at(4) as usize; + let n = u16_at(at) as usize; + let tags = (0..n) + .map(|k| { + let e = at + 2 + k * 12; + let value = if u16_at(e + 2) == 3 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), value) + }) + .collect(); + (tags, u32_at(at + 2 + n * 12)) +} + +fn photo_session(exif: Vec) -> Session { + let mut s = session(8); + s.execute("file.fileInfo", json!({"copyright": "© Example"})).unwrap(); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif)); + Ok(()) + }) + .unwrap(); + s +} + +/// #7436: Save for Web's "All" writes the photo's EXIF, as Save a Copy does: upright +/// (Orientation 1), without the source's thumbnail and at the exported resolution. Every other +/// choice, "All Except Camera Info" included, leaves the camera data (and GPS) out. +#[test] +fn save_for_web_all_writes_the_photos_exif() { + let dir = tmp("exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": metadata, "path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + let exif = exif_app1(&all).expect("an EXIF APP1 segment"); + let camera = photocraft_algo::exif::read(&exif); + assert_eq!(camera.make.as_deref(), Some("TestMake")); + assert_eq!(camera.date_taken.as_deref(), Some("2024:05:01 10:30:00")); + let (tags, next) = ifd0(&exif); + assert_eq!(tags.get(&0x0112), Some(&1), "the exported pixels are already upright"); + assert!(tags.contains_key(&0x8825), "GPS kept"); + assert_eq!(next, 0, "the source's thumbnail is not linked"); + assert_eq!(photocraft_codecs::exif_resolution(&exif), Some((72.0, 72.0)), "the document's resolution, not the source's 300"); + assert!(decode(&format!("{dir}/all.jpg")).meta.xmp.unwrap_or_default().contains("Example")); + for m in ["none", "copyright", "copyrightAndContact", "allExceptCameraInfo"] { + let (bytes, r) = export(&mut s, m); + assert_eq!(exif_app1(&bytes), None, "{m}"); + assert_eq!(r["warnings"], json!([]), "{m}"); + } + assert_eq!(r["warnings"], json!([]), "it fits"); + let xmp = decode(&format!("{dir}/allExceptCameraInfo.jpg")).meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example"), "all the XMP"); + assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); +} + +#[test] +fn quick_export_all_writes_the_photos_exif() { + let dir = tmp("quick_exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": metadata})).unwrap(); + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + assert_eq!(photocraft_algo::exif::read(&exif_app1(&all).expect("EXIF")).make.as_deref(), Some("TestMake")); + assert_eq!(r["warnings"], json!([])); + assert_eq!(exif_app1(&export(&mut s, "copyright").0), None); + assert_eq!(exif_app1(&export(&mut s, "none").0), None); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// EXIF too large for its one segment is left out and said so; a truncated one doesn't panic. +#[test] +fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { + let dir = tmp("exif_bad"); + let mut big = camera_exif(); + big.resize(70_000, 0); + let mut s = photo_session(big); + let path = format!("{dir}/big.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(exif_app1(&std::fs::read(&path).unwrap()), None); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF (70000 bytes)")), "{r}"); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": "all"})).unwrap(); + let r = s.execute("file.export.quickExport", json!({"path": format!("{dir}/quick.jpg")})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF")), "{r}"); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); + + for cut in [3, 9, 40, 100] { + let mut s = photo_session(camera_exif()[..cut].to_vec()); + let path = format!("{dir}/cut{cut}.jpg"); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48)); + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..4267746633 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,7 +16,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true, features = ["unbounded_depth"] } +serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. @@ -34,5 +34,16 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } [target.'cfg(target_vendor = "apple")'.dependencies] libc = "0.2" +# Replacing a file keeps its ACL (#7073). `fcopyfile(COPYFILE_ACL)` is an `unsafe fn` and +# `fclonefileat(CLONE_ACL)` is APFS-only; exacl is a safe wrapper over acl_get_file/acl_set_file. +[target.'cfg(target_os = "macos")'.dependencies] +exacl = "0.13" + +# `ReplaceFileW` is how Windows replaces a file without dropping its DACL, named streams +# (Zone.Identifier, the Mark of the Web) and creation time (#7094, #7096). winsafe's `kernel` +# feature is a dependency-free safe wrapper; the workspace forbids writing the FFI call here. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", features = ["kernel"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 8a02ce5c78..3a4aeca6c0 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -25,8 +25,15 @@ //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" //! error. //! +//! Replacing a file also carries its metadata across — permission bits, extended attributes, +//! the "downloaded from the internet" mark and the access-control list — so that saving a +//! document does not quietly undo a security decision someone made about the file. That part is +//! [`meta`], and it is best effort: it never fails a save and never touches the bytes. +//! //! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step. +pub mod meta; + use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -54,9 +61,16 @@ pub trait AtomicFs { fn remove(&self, path: &Path) -> io::Result<()>; /// Flush a directory entry change (Unix); a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; - /// Copy the destination's permissions onto the temporary file, so replacing a file keeps its - /// mode. Best effort. - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the + /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Replace the **existing** file `to` with `from`. Everywhere but Windows this is + /// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and + /// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the + /// failure-injecting test file systems want. + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + self.rename(from, to) + } } /// The real file system. @@ -97,9 +111,11 @@ impl AtomicFs for RealFs { Ok(()) } } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - let perms = std::fs::metadata(from)?.permissions(); - std::fs::set_permissions(to, perms) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::copy_metadata(from, to) + } + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::replace_existing(from, to) } fn is_readonly(&self, path: &Path) -> bool { std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) @@ -206,12 +222,19 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & } let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; - // Keep the existing file's mode (e.g. 0600) instead of the process default. - if target.exists() { - let _ = fs.copy_permissions(&target, &tmp); + // Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process + // default, its extended attributes, and its ACL. Best effort, and before the swap, so the + // file the user ends up with never passes through a more permissive state. + let replacing = target.exists(); + if replacing { + let _ = fs.copy_metadata(&target, &tmp); } - if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + // Replacing an existing file goes through `replace_existing`, which on Windows is the one + // call that can keep the destination's DACL and named streams; creating a new one is a + // plain rename, with nothing to preserve. + let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) }; + if let Err(e) = retry_rename(retry, swap) { let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs new file mode 100644 index 0000000000..6c7cfbc21e --- /dev/null +++ b/crates/format/src/atomic/meta.rs @@ -0,0 +1,273 @@ +//! What a replacement keeps from the file it replaces. +//! +//! Saving writes a new file and renames it over the old one, so the user gets a file with the +//! right bytes and none of the old file's history: everything the operating system and other +//! programs had attached to the file that was just unlinked is gone. Some of it is a security +//! decision — *this came from the internet*, *only these people may read it* — so a save that +//! drops it quietly weakens the user's protections (#6603, #7073, #7094, #7095, #7096). +//! +//! | | macOS | Linux | Windows | +//! |---|---|---|---| +//! | permission bits | [`copy_metadata`] | [`copy_metadata`] | [`copy_metadata`] (read-only flag) | +//! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | +//! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | +//! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | +//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! +//! Everything here except [`replace_existing`] is **best effort and never touches the file's +//! bytes**: a save that cannot carry a piece of metadata across is still a save, because a +//! document the user can open matters more than a Finder tag. [`replace_existing`] *is* the +//! replacement step, so it holds the atomicity guarantee instead: it never leaves the +//! destination missing, and it falls back to a plain rename rather than fail a save. +//! +//! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which +//! `listxattr` does not report, so copying the listed attributes misses them; they would need +//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. + +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::fd::BorrowedFd; + +/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a +/// megabyte of them is not something a save should spend memory on. +#[cfg(unix)] +const MAX_XATTR_NAMES: usize = 1 << 20; + +/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*` +/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth +/// holding a copy of it in memory during a save. +#[cfg(unix)] +const MAX_XATTR_VALUE: usize = 16 << 20; + +/// First read size for an attribute list or value; grown on `ERANGE`. +#[cfg(unix)] +const XATTR_CHUNK: usize = 4 << 10; + +/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it +/// is about to replace (`from`) onto the temporary file that will replace it (`to`). +/// +/// Errors are the caller's to ignore: each piece is attempted on its own, and the first failure +/// is returned only so a caller that wants to log one can. +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + // The permission bits go on last. Writing an attribute needs the destination open for + // writing, and replacing a read-only (0444) file would copy that mode onto the temporary + // file and then be unable to touch it again. + #[cfg(unix)] + let attributes = copy_xattrs_at(from, to); + #[cfg(not(unix))] + let attributes = Ok(()); + + #[cfg(target_os = "macos")] + let acl = copy_acl(from, to); + #[cfg(not(target_os = "macos"))] + let acl = Ok(()); + + // A save over a 0600 file must not widen it to whatever the process default is. + let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); + + attributes.and(acl).and(permissions) +} + +/// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the +/// descriptors to [`copy_xattrs`]. +#[cfg(unix)] +fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> { + use std::os::fd::AsFd; + let old = std::fs::File::open(from)?; + // Writing an attribute needs write access to the file, not only to its directory. + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_xattrs(old.as_fd(), new.as_fd()) +} + +/// Copy every extended attribute of `from` onto `to`, best effort, through the two open files +/// rather than their paths. +/// +/// Descriptors, not paths, because the automation write path holds capability-checked handles +/// and must never reach back out to an ambient path to finish a write (#7095). The same reason +/// makes this the right shape for [`copy_metadata`] too. +/// +/// An attribute that cannot be read or cannot be set is skipped: `com.apple.provenance` and +/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to +/// be written by an ordinary process, and failing a document save over one would be absurd. +/// `Err` means the attribute *list* could not be read, so nothing was copied. +#[cfg(unix)] +pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> { + use rustix::fs::{XattrFlags, fsetxattr}; + + for name in list_xattrs(from)?.split(|b| *b == 0) { + if name.is_empty() || skip_xattr(name) { + continue; + } + let Some(value) = read_xattr(from, name) else { continue }; + let _ = fsetxattr(to, name, &value, XattrFlags::empty()); + } + Ok(()) +} + +/// Attributes that describe how the *old* file's bytes are stored rather than anything about +/// the document, and so must never be pinned onto a file holding different bytes. +#[cfg(unix)] +fn skip_xattr(name: &[u8]) -> bool { + // `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself. + // The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming + // compressed storage it does not have would read as garbage, so never try. + name == b"com.apple.decmpfs" +} + +/// The NUL-separated attribute names of `fd`. +#[cfg(unix)] +fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::flistxattr(fd, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Ok(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_NAMES => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_NAMES), 0); + } + Err(e) => return Err(e.into()), + } + } +} + +/// The value of one attribute, or `None` if it cannot be read or is larger than +/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute +/// between the listing and the read. +#[cfg(unix)] +fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::fgetxattr(fd, name, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Some(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_VALUE => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_VALUE), 0); + } + Err(_) => return None, + } + } +} + +/// Copy an explicit macOS access-control list (`chmod +a`) from `from` onto `to` (#7073). +/// +/// `fcopyfile(COPYFILE_ACL)` is the obvious call and is an `unsafe fn`, which this workspace +/// forbids; `fclonefileat` with `CLONE_ACL` is safe but only works on APFS and would mean +/// writing every save as a clone-then-truncate. `exacl` is a small, safe wrapper over +/// `acl_get_file`/`acl_set_file` that works on every file system that has ACLs at all. +/// +/// An old file with no ACL leaves `to` alone: the new file may have picked up the folder's +/// inherited entries, and taking access away is a worse surprise than leaving it as the folder +/// says it should be. +#[cfg(target_os = "macos")] +fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { + let entries = exacl::getfacl(from, None)?; + if entries.is_empty() { + return Ok(()); + } + exacl::setfacl(&[to], &entries, None) +} + +/// Replace the **existing** file `to` with `from`, keeping what the destination owns rather than +/// its bytes. +/// +/// Everywhere but Windows this is exactly [`std::fs::rename`], and the metadata is carried by +/// [`copy_metadata`] onto `from` beforehand. On Windows it is `ReplaceFileW`, which preserves +/// the replaced file's DACL (#7096), its named streams — `Zone.Identifier`, the Mark of the Web +/// (#7094) — its creation time, object identifier, compression and encryption, in the one call +/// Microsoft documents for the save-through-a-temporary-file pattern. None of those have a safe +/// path-based API to copy by hand. +pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(windows)] + { + windows_replace(from, to) + } + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } +} + +/// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. +/// +/// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before +/// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no +/// longer exists and the replacement file exists under its original name" — the user's document +/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// leaves both files where they were. So this always passes one, and deletes it on the way out. +/// +/// The failures and what each leaves behind (from the `ReplaceFileW` reference): +/// +/// - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (1177) is the only one that moves anything: the +/// replacement keeps its name but has already inherited the destination's streams and +/// attributes, and the destination's old content is at the backup name. Finishing the rename +/// by hand completes the save *with* its metadata; if even that fails, the backup goes back to +/// the destination so the user's file is where they left it. +/// - Every other error leaves the replaced and replacement files under their own names, so a +/// plain rename still saves the document — without the metadata, which is where this started. +#[cfg(windows)] +fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { + use winsafe::co::REPLACEFILE; + + /// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the destination now lives under the backup name. + const MOVE_REPLACEMENT_2: i32 = 1177; + + let backup = backup_path(to); + let paths = (to.to_str(), from.to_str(), backup.as_deref().and_then(Path::to_str)); + // `ReplaceFileW` takes `&str`; a path that is not valid Unicode, or one past MAX_PATH, is + // the plain rename's job. + let (Some(replaced), Some(replacement), Some(backup_str)) = paths else { + return std::fs::rename(from, to); + }; + + let flags = REPLACEFILE::IGNORE_MERGE_ERRORS | REPLACEFILE::IGNORE_ACL_ERRORS; + match winsafe::ReplaceFile(replaced, replacement, Some(backup_str), flags) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(rename_failed) => { + if let Some(backup) = backup.as_deref() { + let _ = std::fs::rename(backup, to); + } + Err(rename_failed) + } + }, + Err(_) => { + remove_backup(backup.as_deref()); + std::fs::rename(from, to) + } + } +} + +/// A unique name beside `to` for `ReplaceFile` to park the replaced file under. It shares the +/// temporary-file suffix, so the save's own cleanup and the tests' leftover check cover it. +#[cfg(windows)] +fn backup_path(to: &Path) -> Option { + let name = to.file_name()?.to_string_lossy().into_owned(); + let dir = match to.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + Some(dir.join(super::temp_name(&name))) +} + +#[cfg(windows)] +fn remove_backup(backup: Option<&Path>) { + if let Some(backup) = backup { + let _ = std::fs::remove_file(backup); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs new file mode 100644 index 0000000000..5c8f08c6ba --- /dev/null +++ b/crates/format/src/atomic/meta/tests.rs @@ -0,0 +1,309 @@ +//! What a save keeps from the file it replaces. +//! +//! The Unix tests run for real. The Windows ones exercise `ReplaceFileW` through +//! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` +//! and are covered by the Windows CI job, not by a developer on a Mac. + +use crate::atomic::atomic_write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let d = std::env::temp_dir().join(format!("pc-meta-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Self(d) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// No temporary or backup file is left beside the saved document. +fn assert_no_temp(dir: &Path) { + let leftovers: Vec<_> = + std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect(); + assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}"); +} + +// ---------------------------------------------------------------- extended attributes (Unix) + +#[cfg(unix)] +mod xattrs { + use super::*; + + /// macOS wants a reverse-DNS name; Linux only lets an unprivileged process write `user.*`. + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + fn set(path: &Path, name: &str, value: &[u8]) -> bool { + rustix::fs::setxattr(path, name, value, rustix::fs::XattrFlags::empty()).is_ok() + } + + fn get(path: &Path, name: &str) -> Option> { + let mut buf = vec![0u8; 1 << 20]; + let len = rustix::fs::getxattr(path, name, buf.as_mut_slice()).ok()?; + buf.truncate(len); + Some(buf) + } + + /// #6603: the attributes of the replaced file — a Finder tag, a `user.*` attribute — are on + /// the file the user is left with. + #[test] + fn replacing_a_file_keeps_its_extended_attributes() { + let d = TempDir::new("xattr"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + // tmpfs and some CI file systems have no xattr support; nothing to assert. + return; + } + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "the replaced file's attribute was dropped"); + assert_no_temp(&d.0); + } + + /// A large value still comes across (and is not silently truncated). + #[test] + fn a_large_attribute_value_survives() { + let d = TempDir::new("xattr-big"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + // Comfortably past the first read buffer, so the grow-on-ERANGE path runs. + let value = vec![0xABu8; 64 << 10]; + if !set(&p, TAG, &value) { + return; + } + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(get(&p, TAG), Some(value)); + } + + /// A read-only file is refused outright (#6600), so a save never reaches the metadata copy and + /// the file keeps everything it had. The writable cases above cover the copy itself. + #[test] + fn a_read_only_file_keeps_its_attributes_and_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("xattr-ro"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + return; + } + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); + + // #6600: a read-only file is refused before anything is written, so the question this test + // asks — does a replace keep the attributes and the mode — is answered by the save not + // happening. The writable case above covers attributes surviving a replace. + let e = atomic_write(&p, b"second, longer").expect_err("a read-only file is refused"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); + + assert_eq!(std::fs::read(&p).unwrap(), b"first", "the original was left alone"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); + } + + /// Creating a file that was not there sets no attributes and does not fail. + #[test] + fn creating_a_new_file_needs_no_source() { + let d = TempDir::new("xattr-new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert!(get(&p, TAG).is_none()); + } + + /// The descriptor-based copy is what the automation write path uses (#7095): it works on two + /// open files and never looks at a path. + #[test] + fn copy_xattrs_works_on_descriptors() { + use std::os::fd::AsFd; + let d = TempDir::new("xattr-fd"); + let (from, to) = (d.join("from"), d.join("to")); + std::fs::write(&from, b"a").unwrap(); + std::fs::write(&to, b"b").unwrap(); + if !set(&from, TAG, b"carried") { + return; + } + + let old = std::fs::File::open(&from).unwrap(); + let new = std::fs::OpenOptions::new().write(true).open(&to).unwrap(); + super::super::copy_xattrs(old.as_fd(), new.as_fd()).unwrap(); + + assert_eq!(get(&to, TAG).as_deref(), Some(&b"carried"[..])); + assert_eq!(std::fs::read(&to).unwrap(), b"b", "copying attributes must not touch the bytes"); + } +} + +// --------------------------------------------------------------------------- mode (Unix) + +/// The mode was already kept before any of this; it still is. +#[cfg(unix)] +#[test] +fn replacing_a_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); +} + +// ----------------------------------------------------------------------------- ACL (macOS) + +#[cfg(target_os = "macos")] +mod acl { + use super::*; + + /// #7073: an ACL entry added with `chmod +a` is still there after a save. + #[test] + fn replacing_a_file_keeps_its_acl() { + let d = TempDir::new("acl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // The same thing `chmod +a` does: one explicit entry for the user running the test, so + // no second account has to exist. + let Ok(user) = std::env::var("USER") else { return }; + let entries = [exacl::AclEntry::allow_user(&user, exacl::Perm::READ | exacl::Perm::WRITE, None)]; + if exacl::setfacl(&[&p], &entries, None).is_err() { + // A file system without ACL support (a RAM disk, some CI volumes). + return; + } + let before = exacl::getfacl(&p, None).unwrap(); + assert!(!before.is_empty(), "the test could not set an ACL to begin with"); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(exacl::getfacl(&p, None).unwrap(), before, "the replaced file's ACL was dropped"); + assert_no_temp(&d.0); + } + + /// A file with no ACL keeps having no ACL, rather than gaining an empty one. + #[test] + fn a_file_without_an_acl_is_left_alone() { + let d = TempDir::new("acl-none"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = exacl::getfacl(&p, None).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(exacl::getfacl(&p, None).unwrap(), before); + } +} + +// --------------------------------------------------------------- named streams and ACL (Windows) + +/// Unverified on a Mac: these run only on Windows (CI's Windows job). +#[cfg(windows)] +mod windows { + use super::*; + + /// Open a named stream of `path`, e.g. `Zone.Identifier`. + fn stream(path: &Path, name: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(":"); + s.push(name); + PathBuf::from(s) + } + + /// #7094: a file marked as downloaded from the internet is still marked after a save, so the + /// shell and other applications keep warning about it. + #[test] + fn replacing_a_file_keeps_its_mark_of_the_web() { + let d = TempDir::new("motw"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let motw = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(stream(&p, "Zone.Identifier"), motw).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(stream(&p, "Zone.Identifier")).unwrap(), motw, "the Mark of the Web was dropped"); + assert_no_temp(&d.0); + } + + /// Any other named stream rides along too, because `ReplaceFileW` keeps the lot. + #[test] + fn replacing_a_file_keeps_other_named_streams() { + let d = TempDir::new("ads"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::write(stream(&p, "photocraft.test"), b"side data").unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::read(stream(&p, "photocraft.test")).unwrap(), b"side data"); + } + + /// #7096: an explicit ACL entry survives, instead of the file falling back to whatever the + /// folder hands down. Compared as the string `icacls` prints, which is what a user checks. + #[test] + fn replacing_a_file_keeps_its_explicit_acl() { + let d = TempDir::new("dacl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // Grant the current user an entry that inheritance would not have produced. + let user = std::env::var("USERNAME").unwrap_or_default(); + if user.is_empty() { + return; + } + let granted = std::process::Command::new("icacls").arg(&p).arg("/grant").arg(format!("{user}:(RX)")).output(); + if !granted.map(|o| o.status.success()).unwrap_or(false) { + return; + } + let before = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + + atomic_write(&p, b"second, longer").unwrap(); + + let after = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + assert_eq!(String::from_utf8_lossy(&after), String::from_utf8_lossy(&before), "the explicit ACL was dropped"); + assert_no_temp(&d.0); + } + + /// `ReplaceFileW` also keeps the creation time, which the rename used to reset (#6603). + #[test] + fn replacing_a_file_keeps_its_creation_time() { + let d = TempDir::new("born"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = std::fs::metadata(&p).unwrap().created().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(50)); + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), before); + } + + /// Creating a file where there was none still works: there is nothing to replace, so the + /// write never reaches `ReplaceFileW`. + #[test] + fn creating_a_new_file_still_works() { + let d = TempDir::new("new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert_no_temp(&d.0); + } +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index ce3ed1b7c6..d4381705a0 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -69,8 +69,8 @@ impl AtomicFs for Faulty { fn sync_dir(&self, dir: &Path) -> io::Result<()> { RealFs.sync_dir(dir) } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - RealFs.copy_permissions(from, to) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } @@ -201,8 +201,8 @@ impl AtomicFs for ReadOnlyTarget { fn sync_dir(&self, dir: &Path) -> io::Result<()> { RealFs.sync_dir(dir) } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - RealFs.copy_permissions(from, to) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/float_roundtrip.rs new file mode 100644 index 0000000000..da6e7626d1 --- /dev/null +++ b/crates/format/tests/float_roundtrip.rs @@ -0,0 +1,27 @@ +//! `.pcraft` is lossless for f64: every coordinate reloads bit-for-bit. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; + +#[test] +fn f64_coordinates_reload_bit_exact() { + let x = 11.183221215612905_f64; + let y = 0.1 + 0.2; + let mut doc = Document::new("Floats", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Shape", + LayerContent::Shape(photocraft_doc::ShapeLayer { + path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(x, y), (40.0, 10.0), (25.0, 40.0)])]), + ..Default::default() + }), + )); + doc.guides.horizontal.push(x); + doc.guides.vertical.push(y); + doc.ruler_origin = [x, y]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.guides.horizontal[0].to_bits(), x.to_bits()); + assert_eq!(back.guides.vertical[0].to_bits(), y.to_bits()); + assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); + assert_eq!(back, doc); +} diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 924ea4954f..a379d000be 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -442,21 +442,29 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let Some(r) = fields.get(RESUME) else { return }; +pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(r) = fields.get(RESUME) else { return Ok(()) }; // The dialog's command may run as a background job; the next step needs its result. if let Some(doc) = app.session.active().map(|d| d.doc.id) && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) { - let _ = app.session.wait_job(job.id); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); } let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); match play(app, ¶ms) { - Ok(v) => report_play(app, &v), + Ok(v) => { + report_play(app, &v); + Ok(()) + } Err(e) => { let template = "Action playback failed: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); app.ui.status_error = true; + Err(e) } } } @@ -1550,6 +1558,43 @@ mod tests { assert_eq!(app.session.actions.list[0].steps.len(), 3); } + #[test] + fn dialog_worker_failure_stops_action_before_following_steps() { + for (background, locked) in [(true, true), (false, true), (true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + let mut action = Action::new( + "Worker Result", + vec![ + ("layer.setProps".into(), json!({"name": "Prefix", "locks": {"pixels": locked}})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), + ("layer.new.layer".into(), json!({"name": "Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.background_jobs = background; + let paused = play(&mut app, &json!({"action": "Worker Result"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let result = crate::dialogs::confirm(&mut app, dialog); + let suffix = app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Suffix"); + + assert_eq!(suffix, !locked, "background={background}, locked={locked}: {result:?}"); + if locked && background { + assert!(result.as_ref().is_err_and(|e| e.contains("locked")), "failed worker must fail the dialog confirmation: {result:?}"); + assert!(app.ui.status_error, "the action must report the worker failure"); + assert!(app.ui.status.contains("locked"), "{}", app.ui.status); + assert_eq!(app.session.jobs_with_recent()[0].state, "failed"); + } else if locked { + assert!(result.is_err(), "the foreground command rejects the locked layer"); + } else { + assert!(result.is_ok(), "the successful worker lets the action continue: {result:?}"); + } + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + } + } + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index be9acde2e8..fe483caef9 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -79,6 +79,101 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Whether the top modal is the Curves dialog. +pub fn curves_on_top(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| d.fields.get("__adjust").and_then(Value::as_str) == Some("curves")) +} + +/// ⌘/Ctrl-click on the image with Curves on top (#7325): a point on the curve shown in the dialog +/// at the clicked pixel's tone, at the output the curve gives there rounded to a whole level, so the +/// image barely changes (where the curve is clipped at 0 or 255 the point re-shapes it, as a click on +/// the graph there does); with `every_channel` (Shift) one on each colour channel instead, at that +/// channel's value. +/// The pixel comes from the same merged composite as the eyedroppers. Returns true when Curves is +/// on top: the click is the dialog's even when nothing is added (pencil curve, Lab or CMYK, a mask +/// or saved channel target, outside the image, a full curve). +pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, every_channel: bool) -> bool { + if !curves_on_top(app) { + return false; + } + let Some((id, mut values)) = app.ui.dialogs.last().map(|d| (d.id, crate::filter_dialog::params_of(&d.fields))) else { return false }; + if values.get("pencil").and_then(Value::as_bool) == Some(true) { + return true; + } + let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true }; + if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) { + app.ui.status = tl!("Unavailable in this color mode").into(); + return true; + } + // A mask, saved channel or Quick Mask holds its own gray levels, not the composite's. + let isolated = isolated(app); + if isolated.as_ref().is_some_and(|c| c.gray.is_some()) { + return true; + } + let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true }; + let gray = adjust_editors::is_gray(mode); + let (shown, shown_key) = match &isolated { + Some(c) => (0, c.key), + None => { + let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); + let chans = adjust_editors::tone_channels(adjust_editors::space_of(&values), gray); + (ch, chans.get(ch).map_or("", |c| c.key)) + } + }; + let keys: &[&str] = if every_channel && !gray && isolated.is_none() { &["red", "green", "blue"] } else { &[shown_key] }; + let mut selected = None; + let mut changed = false; + for &key in keys { + let level = match key { + "red" => sample[0], + "green" => sample[1], + "blue" => sample[2], + // The composite's tone: Gray's mean, as the Grayscale eyedroppers read it, and RGB's + // Rec. 709 luma, as the Neutral Gray eyedropper does (`adjust_params::rgb_luma`). + _ if gray => (sample[0] + sample[1] + sample[2]) / 3.0, + _ => sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722, + }; + let input = (level.clamp(0.0, 1.0) * 255.0).round(); + let field = if key.is_empty() { "points" } else { key }; + let mut pts = adjust_editors::read_curve(&values, field); + let curve: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&curve); + let at = (input / 255.0 * lut.len().saturating_sub(1) as f32).round() as usize; + let Some(output) = lut.get(at).map(|o| (o * 255.0).round()) else { continue }; + let before = pts.len(); + let Some(i) = crate::point_curve::insert(&mut pts, [input, output]) else { continue }; + if pts.len() > before { + // Whole levels, as the editor writes a point clicked on the graph. + values[field] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::>()); + changed = true; + } + if key == shown_key { + selected = Some(i); + } + } + if let Some(i) = selected { + let state = dialog_mem("curves").with("curves"); + ctx.data_mut(|d| { + let mut st: crate::state::CurvesEditorState = d.get_temp(state).unwrap_or_default(); + st.channel = shown; + st.gesture = crate::state::PointCurveState { selected: Some(i), drag: None }; + d.insert_temp(state, st); + }); + } + if changed + && let Some(dialog) = app.ui.dialog_mut(id) + && let Value::Object(v) = values + { + dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.extend(v); + if picker(dialog.fields.get(CURVE_PICKER)).is_some() { + let base = crate::filter_dialog::params_of(&dialog.fields); + dialog.fields.insert(CURVE_PICKER_BASE.into(), base); + } + } + true +} + pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response { let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner; let tokens = crate::theme::Tokens::get(ui.ctx()); @@ -767,6 +862,139 @@ mod tests { } } + fn pointer_down(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, modifiers: Value) { + let (request, _receiver) = + crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}], "modifiers": modifiers})); + let _ = crate::control::handle(app, ctx, &request); + } + + /// The Curves curve `pts` (0-255 levels) as a 256-entry 0-255 map. + fn curve_levels(pts: &[[f64; 2]]) -> Vec { + let cp: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] as f32 / 255.0, output: q[1] as f32 / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + (0..256).map(|k| lut[(k as f32 / 255.0 * (lut.len() - 1) as f32).round() as usize] * 255.0).collect() + } + + /// #7325: ⌘/Ctrl-click on the image puts a point on the shown curve at the pixel's tone + /// (#b04020: Rec. 709 luma 85.5 → level 86) and at the curve's own output there, so the + /// image does not change; the new point is the selected one, and the document waits for OK. + #[test] + fn command_click_on_the_image_adds_a_point_on_the_composite_curve() { + let mut app = app_with_image(); + let original = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let base = [[0.0, 0.0], [128.0, 200.0], [255.0, 255.0]]; + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(base)); + let ctx = egui::Context::default(); + let sample = crate::canvas::composite_color(&mut app, 8.0, 8.0).unwrap(); + let level = ((sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722) * 255.0).round(); + assert_eq!(level, 86.0); + let output = f64::from(curve_levels(&base)[86].round()); + assert_ne!(output, 86.0, "the base curve is not the identity at the sampled level"); + + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + let points = channel_points(&app, "points"); + assert_eq!(points, vec![base[0], [86.0, output], base[1], base[2]]); + let (before, after) = (curve_levels(&base), curve_levels(&points)); + assert!(before.iter().zip(&after).all(|(a, b)| (a - b).abs() <= 0.5), "the curve keeps its shape:\n{before:?}\n{after:?}"); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (0, Some(1)), "the new point is selected"); + for key in ["red", "green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + /// #7325: Shift+⌘-click puts a point on each colour channel at that channel's own value + /// (176, 64, 32), and leaves the composite curve alone. + #[test] + fn shift_command_click_adds_a_point_on_each_colour_channel() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true, "shift": true})); + for (key, level) in [("red", 176.0), ("green", 64.0), ("blue", 32.0)] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [level, level], [255.0, 255.0]], "{key}"); + } + assert_eq!(channel_points(&app, "points"), composite); + } + + /// #7325: with Red shown in the Channel popup the point goes on the red curve, at red's value. + #[test] + fn command_click_adds_the_point_on_the_channel_shown_in_the_dialog() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(dialog_mem("curves").with("curves-ch"), 1usize)); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "red"), vec![[0.0, 0.0], [176.0, 176.0], [255.0, 255.0]]); + for key in ["green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert_eq!(channel_points(&app, "points"), composite); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (1, Some(1))); + } + + /// #7325: a plain click, a full curve and an armed eyedropper add nothing. + #[test] + fn command_click_adds_nothing_without_the_modifier_on_a_full_curve_or_with_an_eyedropper() { + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({})); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "no modifier, no point"); + // Space and the middle button pan, as with the mouse. + for extra in [json!({"modifiers": {"command": true, "space": true}}), json!({"command": true, "button": "middle"})] { + let mut params = json!({"events": [{"kind": "down", "x": 8, "y": 8}]}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let (request, _receiver) = crate::control::ControlRequest::new("ui.pointer", params); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "{extra}"); + } + + // Sixteen points (the most a curve holds), none within reach of level 86. + let full: Vec<[f64; 2]> = + [0, 10, 20, 30, 40, 50, 60, 70, 80, 100, 120, 140, 160, 180, 200, 255].iter().map(|&i| [f64::from(i), f64::from(i)]).collect(); + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(full)); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points"), full, "a full curve takes no more points and moves none"); + + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + arm(&mut app, id, "black"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points").len(), 2, "the armed eyedropper samples instead"); + assert_eq!(channel_points(&app, "red")[0], [176.0, 0.0]); + } + + /// #7325: Lab and CMYK curves and a targeted mask are left to a later change: nothing is added. + #[test] + fn command_click_adds_nothing_in_lab_or_on_a_targeted_mask() { + let ctx = egui::Context::default(); + let mut lab = app_in_mode("lab"); + lab.run("edit.fill", json!({"color": "#b04020"})).unwrap(); + let id = open(&mut lab, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut lab, &ctx, 4.0, 4.0, false)); + assert_eq!(crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields), before); + + let mut masked = app_with_image(); + masked.run("layer.layerMask.revealAll", json!({})).unwrap(); + masked.run("channel.target", json!({"channel": "mask"})).unwrap(); + let id = open(&mut masked, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut masked, &ctx, 8.0, 8.0, false)); + assert_eq!(crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields), before); + } + #[test] fn ui_pointer_drives_an_armed_curves_picker() { let mut app = app_with_image(); diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 87a81b8c35..fa32a3b392 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -362,7 +362,7 @@ fn curve_graph(full: Rect, side: f32) -> Rect { Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING)) } -fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { +pub(crate) fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { let pts: Vec<[f32; 2]> = v .get(key) .and_then(Value::as_array) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8034eb195..6c47c89897 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1932,12 +1932,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } // Web and explicitly inline sessions keep the deterministic synchronous path (#1676): // computed inline, uploaded when a GPU texture exists, still inspectable without one. + // Flattened once, as the background worker does; `publish_filter_preview` uploads it (#7490). let _ = ctx; if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( + let computed = crate::filter_dialog::preview_document_for_target( &doc, request.active, &request.command, @@ -1948,19 +1949,11 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e &documents, &app.session.color.settings, ) - .map(std::sync::Arc::new); - if let Some(r) = &result { - let buf = photocraft_compose::flatten(r); - let t1 = crate::gpu_canvas::now_ms(); - // The preview's own profile: Assign and Convert to Profile change it (see `publish_filter_preview`). - let (display, _) = canvas_display(app, r, None); - if let Some(gpu) = app.gpu.as_ref() { - gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); - } - app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); - } - let buffer = result.as_ref().map(|r| photocraft_compose::flatten(r)); - publish_filter_preview(app, idx, request, result.zip(buffer), crate::gpu_canvas::now_ms() - t0); + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + publish_filter_preview(app, idx, request, computed, crate::gpu_canvas::now_ms() - t0); } let preview = app.filter_preview.as_ref()?; let result = preview.result.as_ref()?; @@ -3102,12 +3095,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }); let direct_input = pointer_here || (hover_here - && crate::wheel_nav::read( + && crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, wheel_owner, hover_here, + app.session.prefs().interface.enable_animations, + app.session.prefs().enhanced_controls.touch_gestures, ) .is_some()); if direct_input { @@ -3425,6 +3420,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor + // turn the view (#204); the mouse and trackpad are unaffected. + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; let wheel = crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, @@ -3432,6 +3430,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: wheel_owner, nav_hovered, app.session.prefs().interface.enable_animations, + touch_gestures, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -3464,7 +3463,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) { + if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad, touch_gestures) { view.zoom_animating = false; view.zoom_anchor = None; let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); @@ -3538,7 +3537,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). let shadow_drag = !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; + // ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The + // press's own modifiers decide, and only the press adds: a drag would add one per frame. + let curve_point_press = (primary && !middle && crate::adjust_dialog::curves_on_top(app)) + .then(|| { + ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) + }) + .flatten() + .map(|m| crate::workspace_ui::sticky_mods(app, m)) + .filter(|m| m.command); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none(); if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3583,6 +3596,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q)); crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x()); + } else if let Some(mods) = curve_point_press + && let Some(p) = crate::dialogs::free_press(&ctx, rect) + { + let d = xf.to_doc(p); + crate::adjust_dialog::add_point_at(app, &ctx, d[0], d[1], mods.shift); } else if primary && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p)) @@ -4535,7 +4553,7 @@ pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator< }; for (pa, pb) in lines { // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). - if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) { continue; } let (pa, pb) = if pa.distance(pb) > LONG { @@ -6312,6 +6330,26 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #7490: the inline (web) preview path composites and uploads each changed preview once, as + /// the background worker does. It used to flatten and upload it twice and record it twice, + /// so the frame's refresh time counted the preview twice. + #[test] + fn inline_filter_preview_is_composited_and_recorded_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + app.perf.frame_refresh_ms = 0.0; + let (_, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + assert_eq!(size, [320, 240]); + assert_eq!(app.perf.last_refresh, "filter-preview"); + assert_eq!(app.perf.last_refresh_px, 320 * 240); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); + // Unchanged request: nothing is recomputed or recorded again. + ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("the same preview"); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); + } + /// #6326: a filter's live preview is never coarser than the screen. On a document big enough /// for a reduced proxy, the view at 100 % previews at full resolution, and only a view zoomed /// out far enough uses the proxy. Through `ensure_filter_preview`, so the factor the canvas diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index f1bbbe8d0c..839dc1f765 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -248,6 +248,22 @@ fn control_channel_opening_uses_the_same_dismissal_guard() { } } +/// #7324: an agent's right-click on committed type opens the type layer menu, as on the canvas. +#[test] +fn control_channel_right_click_on_committed_type_opens_the_type_layer_menu() { + let mut h = harness(Tool::Type, false, 1.0); + h.state_mut().run("type.create", json!({"text": "Hello", "size": 72, "x": 100, "y": 180})).unwrap(); + h.run_steps(3); + let at = fractional_point(&h, 1.0); + let before = document_state(&h); + control_tap(&mut h, at, Popup::Tool); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.type_layer && !menu.text); + assert!(h.state().ui.text_edit.is_none()); + // The new type is already the active layer, so the menu selects nothing either. + assert!(document_state(&h) == before, "opening the menu changes nothing"); +} + #[test] fn brush_picker_taps_preserve_pixels_selection_and_history_after_reopening() { for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 7e66660d8c..f4e1d4e9a8 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,6 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free -//! Transform box, text being edited with a Type tool, the pasteboard (outside the image) and a guide. +//! Transform box, text being edited with a Type tool, committed type under a Type tool, the +//! pasteboard (outside the image) and a guide. use egui::Context; use photocraft_doc::LayerContent; @@ -31,6 +32,9 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// Opened with a Type tool over committed type (no edit running): the type layer rows. + #[serde(default)] + pub type_layer: bool, /// Opened on a guide: the guide menu (`guide_select`) instead of the tool's actions. #[serde(default)] pub guide: bool, @@ -137,6 +141,33 @@ pub const TYPE_EDIT_MENU: &[Row] = &[ Some(("Warp Text…", "type.warpText")), ]; +/// Right-click with a Type tool over type that isn't being edited (#7324): the type edit menu's +/// layer rows. Its edit rows would act on the document instead of the text, and Faux Bold/Italic +/// on characters being edited, so they are left out. +pub const TYPE_LAYER_MENU: &[Row] = &[ + Some(("Check Spelling…", "edit.checkSpelling")), + Some(("Find and Replace Text…", "edit.findAndReplaceText")), + None, + Some(("Rasterize Type", "type.rasterizeTypeLayer")), + Some(("Create Work Path", "type.createWorkPath")), + Some(("Convert to Shape", "type.convertToShape")), + None, + Some(("Horizontal", "type.orientation.horizontal")), + Some(("Vertical", "type.orientation.vertical")), + None, + Some(("None", "type.antiAlias.none")), + Some(("Sharp", "type.antiAlias.sharp")), + Some(("Crisp", "type.antiAlias.crisp")), + Some(("Strong", "type.antiAlias.strong")), + Some(("Smooth", "type.antiAlias.smooth")), + Some(("Windows LCD", "type.antiAlias.windowsLcd")), + Some(("Windows", "type.antiAlias.windows")), + None, + Some(("Convert to Paragraph Text", "type.convertToParagraphText")), + Some(("Convert to Point Text", "type.convertToPointText")), + Some(("Warp Text…", "type.warpText")), +]; + /// The type edit menu's Faux Bold and Faux Italic rows. pub const FAUX_BOLD: &str = "type.fauxBold"; pub const FAUX_ITALIC: &str = "type.fauxItalic"; @@ -316,6 +347,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU + } else if menu.type_layer { + TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU } else { @@ -338,7 +371,7 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); } - if menu.text { + if menu.text || menu.type_layer { return type_checked(app, command); } menu.transform @@ -364,6 +397,32 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The committed type layer under document point `doc` when a right-click there with a Type tool +/// opens the type layer menu (#7324). The Type Mask tools are left out: to them existing type is +/// just pixels. +pub fn type_layer_at(app: &mut PhotocraftApp, tool: Tool, doc: [f64; 2]) -> Option { + if !tool.is_type() || tool.is_type_mask() || app.ui.text_edit.is_some() || !doc.iter().all(|v| v.is_finite()) { + return None; + } + crate::type_tool::hit_layer(app, doc[0], doc[1]) +} + +/// Make the right-clicked type layer `id` the active layer, which the structural rows (Rasterize +/// Type, Convert to Shape…) act on and the checks show. Inside a multiselection the selection stays, +/// so the formatting rows still act on every selected type layer, as the Type menu's do; outside it +/// the type replaces the selection, as a click with the Type tool does. +fn target_type_layer(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) -> bool { + let Some(st) = app.session.active() else { return false }; + let selected = st.selected_layers(); + if st.active_layer == Some(id) { + true + } else if selected.contains(&id) { + photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, selected, Some(id), Some(id)).is_ok() + } else { + app.session.select_layer(id).is_ok() + } +} + /// 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; @@ -424,6 +483,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -497,6 +557,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -525,6 +586,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui pasteboard: false, anchor: None, text: false, + type_layer: false, guide: true, spelling: None, }); @@ -578,6 +640,9 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } + if menu.type_layer { + return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -621,10 +686,15 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - } /// [`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. +/// edited, the menu starts with its spelling suggestions; over committed type with a Type tool, the +/// type layer menu. 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()) { + if !pos.iter().all(|v| v.is_finite()) { + return false; + } + let type_layer = !text && doc.and_then(|d| type_layer_at(app, tool, d)).is_some_and(|id| target_type_layer(app, id)); + if !(applies(tool) || text || type_layer) { return false; } app.ui.brush_picker = None; @@ -633,8 +703,20 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; - app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, guide: false, spelling }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection, + has_path, + path_name, + transform: false, + pasteboard: false, + anchor: None, + text, + type_layer, + guide: false, + spelling, + }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -657,6 +739,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + type_layer: false, guide: false, spelling: None, }); @@ -706,7 +789,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_view(app, ctx, &menu, command) } else if menu.text && is_spelling(&menu, command) { choose_spelling(app, &menu, command) - } else if menu.text { + } else if menu.text || menu.type_layer { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) @@ -843,6 +926,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) || (menu.text && app.ui.text_edit.is_none()) + || (menu.type_layer && app.ui.text_edit.is_some()) { app.ui.canvas_tool_menu = None; return; @@ -1058,6 +1142,91 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + /// A committed type layer reading "Hello" over the middle of the canvas, no edit running. + fn committed_type(app: &mut PhotocraftApp) -> u64 { + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Hello", "size": 14, "x": 1, "y": 22})).unwrap()["layer"].as_u64().unwrap(); + assert!(app.ui.text_edit.is_none()); + id + } + + #[test] + fn right_click_on_committed_type_opens_the_type_layer_menu() { + use photocraft_doc::text::Orientation; + let mut app = app(); + let id = committed_type(&mut app); + assert_eq!(crate::type_tool::hit_layer(&mut app, 16.0, 16.0).map(|l| l.0), Some(id), "the canvas centre is on the type"); + let mut h = harness(app); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on committed type opens a menu (#7324)"); + assert!(menu.type_layer && !menu.text && !menu.pasteboard); + assert_eq!(rows(&menu), TYPE_LAYER_MENU); + assert!(h.state().ui.text_edit.is_none(), "the right-click doesn't start an edit"); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu stays open without an edit"); + // An edit started some other way (here, as an agent's Type › Edit Text would) closes the + // menu, which has no text edit rows. + crate::menus::invoke(h.state_mut(), &Context::default(), "type.editText", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_none(), "an edit closes the type layer menu"); + crate::type_tool::commit(h.state_mut()); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("committed again, the menu opens again"); + assert!(menu.type_layer && mode_checked(h.state(), &menu, "type.orientation.horizontal")); + choose(h.state_mut(), &Context::default(), "type.orientation.vertical"); + assert_eq!(text_of(h.state(), id).unwrap().orientation, Orientation::Vertical); + } + + #[test] + fn type_layer_menu_targets_the_type_under_the_pointer() { + let mut app = app(); + let id = committed_type(&mut app); + app.run("layer.new.layer", json!({})).unwrap(); + let ctx = Context::default(); + let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.map(|l| l.0); + assert_ne!(active(&app), Some(id)); + // Away from the type, or with a Type Mask tool, nothing opens and nothing is selected. + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([30.0, 2.0]))); + assert!(!open_at(&mut app, &ctx, Tool::HorizontalTypeMask, [10.0, 10.0], Some([16.0, 16.0]))); + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([f64::NAN, 16.0]))); + assert_ne!(active(&app), Some(id)); + // Over the type: its layer becomes the target, and the menu has no text edit rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([16.0, 16.0]))); + assert_eq!(active(&app), Some(id)); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + for edit_row in ["edit.undo", "edit.cut", "edit.copy", "edit.paste", "select.all", FAUX_BOLD, FAUX_ITALIC] { + assert!(!available(&app, &menu, edit_row), "{edit_row} belongs to the edit menu only"); + } + assert!(entry_enabled(&app, &menu, "type.convertToParagraphText") && !entry_enabled(&app, &menu, "type.convertToPointText")); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, id).is_none(), "the type layer is pixels now"); + assert!(app.ui.text_edit.is_none()); + } + + /// Two selected type layers: the one right-clicked becomes active, and the selection stays. + #[test] + fn type_layer_menu_in_a_multiselection_acts_on_the_clicked_type() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Type; + let top = app.run("type.create", json!({"text": "Top", "size": 30, "x": 10, "y": 40})).unwrap()["layer"].as_u64().unwrap(); + let bottom = app.run("type.create", json!({"text": "Low", "size": 30, "x": 10, "y": 90})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": bottom})).unwrap(); + app.run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + let at = [25.0, 80.0]; + assert_eq!(crate::type_tool::hit_layer(&mut app, at[0], at[1]).map(|l| l.0), Some(bottom)); + assert_eq!(app.session.active().unwrap().active_layer.map(|l| l.0), Some(top)); + let ctx = Context::default(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some(at))); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer.map(|l| l.0), Some(bottom), "the clicked type is the target"); + assert_eq!(st.selected_layers().len(), 2, "the multiselection stays"); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, bottom).is_none(), "the clicked type is rasterized"); + assert!(text_of(&app, top).is_some(), "not the other selected type"); + } + #[test] fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c48cf2d6b8..4bbb226c36 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1047,6 +1047,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + // ⌘/Ctrl-click with Curves open adds a point at the pixel's tone, on press only (#7325). + if !secondary && !space && button != "middle" && mods.command && crate::adjust_dialog::curves_on_top(app) { + if matches!(ev, ToolEvent::Down { .. }) { + crate::adjust_dialog::add_point_at(app, ctx, x, y, mods.shift); + } + continue; + } // A Properties Levels/Curves eyedropper owns the primary button the same way. if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() { if matches!(ev, ToolEvent::Down { .. }) { @@ -1097,7 +1104,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } - if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { + if crate::canvas_tool_menu::applies(app.ui.tool) + || crate::canvas_tool_menu::editing_text(app, app.ui.tool) + || crate::canvas_tool_menu::type_layer_at(app, app.ui.tool, [x, y]).is_some() + { if down { crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 81e04e570c..2e951e2ece 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -664,7 +664,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { if result.is_ok() { crate::filter_dialog::remember(app, &cmd, &d.fields); // A step dialog of a playing action: the action continues after it. - crate::actions::resume(app, &d.fields); + crate::actions::resume(app, &d.fields)?; } result } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..a84bfe7779 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -76,7 +76,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V app.session.active()?; json!({"dialog": open_print(app)}) } - "file.printOneCopy" if app.session.file_menu.last_print.is_none() => { + "file.printOneCopy" if photocraft_engine::print_cmds::last_print(&app.session).is_none() => { app.session.active()?; json!({"dialog": open_print(app)}) } @@ -729,7 +729,8 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { // Asked once per opening: the Printer menu lists what the spooler knows now. let printers = app.session.execute("file.printers", json!({})).unwrap_or_default(); f.insert("__systemPrinters".into(), printers["printers"].clone()); - if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { + // The settings of the last print, also from an earlier session (#7410). + if let Some(Value::Object(m)) = photocraft_engine::print_cmds::last_print(&app.session) { f.extend(m); } for (k, v) in [ @@ -1452,6 +1453,23 @@ mod tests { assert!(params(f).get("__printers").is_none()); } + /// #7410: File › Print opens with the settings of the last print, also those remembered from + /// an earlier session (the preferences), before anything was printed in this one. + #[test] + fn print_dialog_opens_with_the_settings_remembered_from_an_earlier_session() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + photocraft_engine::print_cmds::REMEMBERED.into(), + json!({"paper": "a5", "scale": 50, "orientation": "landscape", "intent": "perceptual"}), + ); + }); + let id = open_print(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((&f["paper"], &f["scale"], &f["orientation"], &f["intent"]), (&json!("a5"), &json!(50), &json!("landscape"), &json!("perceptual"))); + assert_eq!(f["copies"], 1, "what wasn't remembered keeps its default"); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { @@ -1656,7 +1674,7 @@ mod web_playback_tests { let doc = web_frame_document(&app.session.active().unwrap().doc, Some(frame)).unwrap(); let (proxy, _, _) = web_cmds::web_document(&doc, &json!({"width":16,"height":12}), &settings).unwrap(); let pixels = photocraft_compose::flatten(&proxy); - let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, 72.0, true).unwrap(); + let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, None, 72.0, true).unwrap(); assert_eq!(&optimized.preview[..4], expected, "depth={depth} frame={frame}"); } } diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs index a9ee7a933c..b603584573 100644 --- a/crates/ui-egui/src/file_ui/web_preview.rs +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -60,8 +60,8 @@ pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result< 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())?; + let mut optimized = web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, 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); @@ -137,7 +137,7 @@ pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, p } 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) + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, None, 72.0, true) .map_err(|e| e.to_string())?, ); ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 9b7e1e91b9..6fe4d52ba3 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -111,7 +111,8 @@ pub fn parse_spec(spec: &str) -> Vec { Kind::Color(crate::color_picker_ui::parse_hex(default)) } else if v == "doc" { Kind::Document - } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') { + } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') || v.trim_end_matches('?') == "index|name" { + // `index|name`: a reference (an alpha channel…) the dialog sets through another control. Kind::Json } else if let Some(n) = v.strip_prefix("int[").and_then(|r| r.strip_suffix(']')).and_then(|n| n.parse().ok()) { Kind::Grid(n) @@ -468,6 +469,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } + if command == LENS_BLUR { + lens_blur_channels(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -594,6 +598,37 @@ fn uses_logarithmic_slider(min: f32, max: f32) -> bool { min > 0.0 && max / min > 500.0 } +const LENS_BLUR: &str = "filter.blur.lensBlur"; + +/// Lens Blur's Depth Map Source lists the document's alpha channels after None, Transparency +/// and Layer Mask, as in Photoshop (#7400): their names go to `__channels`. A `channel` source +/// without a channel this document has (references aren't remembered) opens as None. +fn lens_blur_channels(app: &PhotocraftApp, f: &mut Map) { + let names: Vec = app.session.active().map(|d| d.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let known = f.get("depthChannel").and_then(|v| channel_index(v, &names)).is_some(); + if f.get("depthMap").and_then(Value::as_str) == Some("channel") && !known { + f.insert("depthMap".into(), json!("none")); + f.remove("depthChannel"); + } + f.insert("__channels".into(), json!(names)); +} + +/// Lens Blur's Source menu entry for the alpha channel at `index`. By index, not name: two +/// channels may share a name, and each must be choosable. +fn channel_choice(index: usize) -> String { + format!("channel:{index}") +} + +/// The alpha channel `v` names (an index, or a name: the first channel so named), as the +/// engine reads `depthChannel`. +fn channel_index(v: &Value, names: &[String]) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < names.len()), + Value::String(name) => names.iter().position(|n| n == name), + _ => None, + } +} + /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; @@ -767,6 +802,23 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string(); let labels: Vec = options.iter().map(|o| choice_label(o)).collect(); let mut opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect(); + // Lens Blur's Source: each alpha channel by its name, in place of `channel`. + let depth = cmd == LENS_BLUR && p.key == "depthMap"; + let names: Vec = if depth { + f.get("__channels").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() + } else { + Vec::new() + }; + let channels: Vec = (0..names.len()).map(channel_choice).collect(); + if depth { + opts.retain(|(v, _)| v != "channel"); + opts.extend(channels.iter().cloned().zip(names.iter().map(String::as_str))); + if cur == "channel" + && let Some(i) = f.get("depthChannel").and_then(|v| channel_index(v, &names)) + { + cur = channel_choice(i); + } + } // Like Photoshop, the profile menus also list every profile installed in // the system (printer and paper profiles), by description. let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { @@ -788,6 +840,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); + if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::().ok()) { + f.insert("depthChannel".into(), json!(i)); + cur = "channel".into(); + } else if depth { + f.remove("depthChannel"); + } f.insert(p.key.clone(), json!(cur)); }); } @@ -1468,6 +1526,38 @@ mod tests { assert_eq!(params["amount"].as_f64(), Some(37.5)); } + /// #7400: Lens Blur's Source lists the document's alpha channels, by name, each choosable even + /// when two share a name; `depthChannel` is set through that menu, not shown as a field, and + /// like the other references it isn't remembered, so a remembered `channel` source opens as None. + #[test] + fn lens_blur_source_lists_the_alpha_channels() { + let spec = photocraft_engine::commands::find(LENS_BLUR).unwrap().params; + let params = parse_spec(spec); + assert!(params.iter().any(|p| p.key == "depthChannel" && p.kind == Kind::Json), "depthChannel is no dialog field"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__channels"], json!(["Depth Map"])); + // The menu's values are channel indices, so two channels with one name are both choosable. + app.run("channel.new", json!({"name": "Depth Map", "fill": "black"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + let names: Vec = serde_json::from_value(app.ui.dialog_mut(id).unwrap().fields["__channels"].clone()).unwrap(); + assert_eq!(names, ["Depth Map", "Depth Map"]); + assert_eq!((channel_choice(0), channel_choice(1)), ("channel:0".to_string(), "channel:1".to_string())); + assert_eq!(channel_index(&json!(1), &names), Some(1), "the second one by its index"); + assert_eq!(channel_index(&json!("Depth Map"), &names), Some(0), "a name is the first so named, as the engine reads it"); + assert_eq!(channel_index(&json!(2), &names), None); + + app.session.prefs.edit(|p| { + p.dialogs.insert(LENS_BLUR.into(), json!({"depthMap": "channel", "depthChannel": "Depth Map"})); + }); + let id = open(&mut app, LENS_BLUR).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["depthMap"], "none", "no channel was remembered with it"); + assert!(f.get("depthChannel").is_none()); + } + #[test] fn wide_positive_ranges_use_the_logarithmic_slider_path() { assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius"); diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a170c96632..d5d58b2ff2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -743,6 +743,7 @@ Brush Settings 브러시 설정 Brushes 브러시 Channels 채널 + Channel 채널 Character 문자 Character Styles 문자 스타일 Clone Source 복제 원본 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8928778e47..0f242f8f63 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -772,6 +772,7 @@ Brush Settings 画笔设置 Brushes 画笔 Channels 通道 + Channel 通道 Character 字符 Character Styles 字符样式 Clone Source 仿制源 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0d68180573..621bee5518 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -777,6 +777,7 @@ Brush Settings 筆刷設定 Brushes 筆刷 Channels 色版 + Channel 色版 Character 字元 Character Styles 字元樣式 Clone Source 仿製來源 diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..e06182870c 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -317,6 +317,56 @@ fn curves_picker_samples_document_coordinates_through_the_view_transform() { assert!((red - 32.0).abs() < 0.6, "sampled document-right blue patch through the flipped 400% view: {red}"); } +/// #7325: ⌘/Ctrl-click on the image with Curves open adds a point at the pixel's tone. The +/// modifier is the press's own, a drag onto another tone adds nothing more, and the Hand tool +/// does not take the press for a pan. +#[test] +fn command_click_under_curves_adds_one_point_per_press_not_per_frame() { + // The Hand tool too: the press is the dialog's, not the start of a pan. + for hand in [false, true] { + let mut h = harness(); + if hand { + h.state_mut().ui.tool = crate::state::Tool::Hand; + } + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#804020"})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 80, "y": 0, "width": 320, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#20a0e0"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + let view = &mut h.state_mut().ui.views[0]; + (view.zoom, view.center, view.fit_pending) = (4.0, [200.0, 150.0], false); + let dialog = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(3); + let points = |h: &Harness<'static, PhotocraftApp>| { + let d = h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap(); + d.fields.get("points").and_then(serde_json::Value::as_array).map_or(0, Vec::len) + }; + let canvas = h.state().last_canvas_rect; + // Document x 65 (the brown strip) and 100 (the blue), both left of the centred dialog. + let (brown, blue) = (pos2(canvas.left() + 60.0, canvas.center().y), pos2(canvas.left() + 200.0, canvas.center().y)); + h.hover_at(brown); + h.run_steps(1); + press(&mut h, brown, true); + press(&mut h, brown, false); + assert_eq!(points(&h), 2, "a plain click adds no point (hand: {hand})"); + + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::COMMAND }; + // ⌘ stays held through the drag, so only the press can tell the frames apart. + h.event(egui::Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(button(brown, true)); + h.run_steps(1); + assert_eq!(points(&h), 3, "the press adds the point (hand: {hand})"); + for t in [0.25, 0.5, 0.75, 1.0] { + h.event(egui::Event::PointerMoved(brown + (blue - brown) * t)); + h.run_steps(1); + } + h.event(button(blue, false)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(points(&h), 3, "dragging onto another tone adds nothing more (hand: {hand})"); + } +} + /// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100 /// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends /// the selection by a word. diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..70ce973be0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -84,6 +84,21 @@ fn frames_for_at_least(h: &mut Harness<'_, PhotocraftApp>, ms: u64, min_frames: (n, worst) } +/// Step at least `min` frames and until `ready` holds; returns (frames, slowest frame in ms). +/// The test fails when `ready` still does not hold after 60 s. +fn frames_until(h: &mut Harness<'_, PhotocraftApp>, min: u64, ready: impl Fn(&Harness<'_, PhotocraftApp>) -> bool) -> (u64, f64) { + let t = Instant::now(); + let (mut n, mut worst) = (0, 0.0f64); + while n < min || !ready(h) { + assert!(t.elapsed() < Duration::from_secs(60), "not ready after {n} frames"); + let f = Instant::now(); + h.step(); + worst = worst.max(f.elapsed().as_secs_f64() * 1000.0); + n += 1; + } + (n, worst) +} + #[test] fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let mut h = app_harness(); @@ -91,7 +106,9 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let doc_before = h.state().session.active().unwrap().doc.clone(); let job = slow_job(h.state_mut(), &gate); let frame0 = h.state().frame; - let (n, worst) = frames_for_at_least(&mut h, 400, 10); + // The worker thread reports "Filtering tiles" when it starts, which can be after the dialog appears. + let (n, worst) = frames_until(&mut h, 10, |h| h.query_by_label("Filtering tiles").is_some()); + assert!(h.state().session.job(job).is_some(), "the job is still running"); assert!(h.state().frame - frame0 >= 10, "the UI kept rendering ({n} frames)"); eprintln!("{n} frames during the job, slowest {worst:.1} ms"); // The status bar shows the job, and after the delay the modal progress dialog. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..5948bd4edf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -605,9 +605,9 @@ pub struct PhotocraftApp { /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the - /// document they belong to, and their tint on the canvas (`retouch_ui`). + /// document, layer and channel they belong to, and their tint on the canvas (`retouch_ui`). pub(crate) remove_pending: Vec>, - pub(crate) remove_pending_doc: Option, + pub(crate) remove_pending_target: Option, pub(crate) remove_trail: Option, /// The open menus' items, reused between frames (`menus::ItemCache`). pub(crate) menu_cache: Option, @@ -879,7 +879,7 @@ impl PhotocraftApp { committed_stroke: None, trail: None, remove_pending: Vec::new(), - remove_pending_doc: None, + remove_pending_target: None, remove_trail: None, menu_cache: None, move_preview: None, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b19fec6f30..ff26dc3c60 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1996,8 +1996,10 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Use the canvas wheel classifier to honour one ×1.1 step per notch without // applying egui's smoothed wheel tail a second time. let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; if resp.hovered() - && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true) + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6a71a9254b..7bf2110e40 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3506,11 +3506,13 @@ mod tests { assert!(!prefs::is_hidden("type.useEscToCommit")); assert!(!prefs::is_hidden("type.smartQuotes")); assert!(!prefs::is_hidden("type.showFontNamesInEnglish")); - // Rotate View with Trackpad and the pinch zoom are live; the other Enhanced Controls rows - // stay hidden. + // Rotate View with Trackpad, the pinch zoom and Enable Gestures are live; the other + // Enhanced Controls rows stay hidden. assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); + assert!(!prefs::is_hidden("enhancedControls.touchGestures")); + assert!(prefs::is_hidden("enhancedControls.scrubbySliderAcceleration")); // Camera Raw Defaults shows only "Open in Camera Raw" so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 417df92b6e..d79a5385e3 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -166,15 +166,31 @@ fn remove_params(o: &crate::state::ToolOptions) -> Value { json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) } +/// The selection a queued Remove stroke was drawn against. A later selection must not +/// redirect the queued strokes to a different layer or colour component. +#[derive(Clone, PartialEq)] +pub(crate) struct RemoveTarget { + doc: photocraft_doc::DocId, + layer: Option, + channel: photocraft_engine::channel_cmds::ChannelTarget, + paint: Value, +} + +fn remove_target(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(RemoveTarget { doc: st.doc.id, layer: st.active_layer, channel: st.channel_view.target, paint: crate::canvas::paint_target(app) }) +} + /// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas, /// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them). pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let Some(target) = remove_target(app) else { return }; let Some(st) = app.session.active() else { return }; - let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]); - if app.remove_pending_doc != Some(doc) { + let size = [st.doc.size.width, st.doc.size.height]; + if app.remove_pending_target.as_ref() != Some(&target) { app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = Some(doc); + app.remove_pending_target = Some(target); } let diameter = app.session.tools.brush.size; app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed_stroke(points, diameter); @@ -185,12 +201,13 @@ pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { /// Apply the waiting strokes as one removal (one job, one undo step). False when none wait. pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { - if app.remove_pending.is_empty() { + // Enter can arrive before the next canvas frame checks pending-stroke lifetime. + if !remove_pending_live(app) { return false; } let strokes = std::mem::take(&mut app.remove_pending); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; let mut p = remove_params(&app.ui.tool_options); p["strokes"] = json!(strokes); p["target"] = crate::canvas::paint_target(app); @@ -209,7 +226,7 @@ pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool { } app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; app.ui.status.clear(); app.ui.status_error = false; true @@ -221,8 +238,7 @@ pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool { if app.remove_pending.is_empty() { return false; } - let doc = app.session.active().map(|st| st.doc.id); - if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc { + if app.ui.tool == Tool::Remove && remove_target(app).as_ref() == app.remove_pending_target.as_ref() { return true; } remove_pending_cancel(app); @@ -1181,6 +1197,55 @@ mod tests { assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); } + #[test] + fn queued_remove_cannot_commit_after_layer_or_channel_target_changes() { + for change in ["layer", "color", "mask"] { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + if change == "color" { + app.run("channel.target", json!({"channel": "red"})).unwrap(); + } + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1); + assert!(remove_pending_live(&mut app)); + + match change { + "layer" => { + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + } + "color" => { + app.run("channel.target", json!({"channel": "blue"})).unwrap(); + } + _ => { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + } + } + + let journal = app.session.journal.len(); + assert!(!remove_pending_commit(&mut app), "{change}: Enter must not retarget a queued stroke"); + assert_eq!(app.session.journal.len(), journal, "{change}: no Remove command ran"); + assert!(app.remove_pending.is_empty() && app.remove_trail.is_none(), "{change}: queue cleared"); + assert!(app.remove_pending_target.is_none()); + } + } + + #[test] + fn queued_remove_starts_a_new_queue_after_target_switch() { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + remove_pending_add(&mut app, &[[40.0, 10.0, 1.0], [44.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1, "strokes from different layers must not be combined"); + assert_eq!(app.remove_pending[0][0][0], 40.0); + assert!(remove_pending_live(&mut app)); + } + #[test] fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { let mut app = app(); diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 5e173da5dc..5137b9d312 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -4,7 +4,7 @@ //! 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. +//! Trackpad; a two-finger twist on a touch screen also needs Enable Gestures (#204). use egui::{Align2, Pos2, Rect, Stroke, pos2, vec2}; use serde_json::{Value, json}; @@ -137,7 +137,9 @@ pub fn apply_trackpad(current: f32, enabled: bool, delta_rad: f32) -> Option Option { +/// The trackpad's own `Rotate` events always count; a touch screen's two-finger twist (egui's +/// multi-touch rotation) only while `touch_gestures` (Enhanced Controls › Enable Gestures) is on. +pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool) -> Option { if !enabled { return None; } @@ -150,7 +152,13 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option { _ => None, }) .sum(); - if from_events != 0.0 { from_events } else { i.rotation_delta() } + if from_events != 0.0 { + from_events + } else if touch_gestures { + i.rotation_delta() + } else { + 0.0 + } }); apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) } @@ -324,8 +332,8 @@ mod tests { let mut disabled = None; let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { - enabled = trackpad_delta(ui.ctx(), true); - disabled = trackpad_delta(ui.ctx(), false); + enabled = trackpad_delta(ui.ctx(), true, true); + disabled = trackpad_delta(ui.ctx(), false, true); }); output.textures_delta.clear(); @@ -333,6 +341,36 @@ mod tests { assert_eq!(disabled, None); } + /// Preferences › Enhanced Controls › Enable Gestures (#204): off, a two-finger twist on a + /// touch screen doesn't turn the view; the trackpad's `Rotate` event still does. + #[test] + fn enable_gestures_switches_touch_screen_rotation() { + use egui::TouchPhase::{Move, Start}; + let touch = |id: u64, phase, pos| Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos, force: None }; + let twist = |touch_gestures: bool, extra: Vec| { + let ctx = egui::Context::default(); + let frames = [ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(pos2(50.0, 50.0))], + vec![touch(1, Start, pos2(40.0, 50.0)), touch(2, Start, pos2(60.0, 50.0))], + // A quarter turn about the centre between the fingers. + [vec![touch(1, Move, pos2(50.0, 40.0)), touch(2, Move, pos2(50.0, 60.0))], extra].concat(), + ]; + let mut last = None; + for (n, events) in (0u32..).zip(frames) { + let raw = egui::RawInput { time: Some(f64::from(n) / 60.0), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| last = trackpad_delta(ui.ctx(), true, touch_gestures)); + out.textures_delta.clear(); + } + last + }; + let on = twist(true, Vec::new()); + assert!(on.is_some_and(|d| (d.abs() - 90.0).abs() < 1e-3), "on: the fingers turn the view: {on:?}"); + assert_eq!(twist(false, Vec::new()), None, "off: the fingers don't"); + let trackpad = twist(false, vec![Event::Rotate(std::f32::consts::FRAC_PI_6)]); + assert!(trackpad.is_some_and(|d| (d - 30.0).abs() < 1e-3), "off: a trackpad rotate still turns: {trackpad:?}"); + } + #[test] fn trackpad_pref_gates_rotation() { assert_eq!(apply_trackpad(10.0, false, 0.5), None); diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 4336315dba..ffc1a7f6e5 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -215,7 +215,9 @@ fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bo } /// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout, -/// but types `:` on Nordic and German ones, so its character would never match. +/// but types `:` on Nordic and German ones, so its character would never match. Only ⌘ shortcuts +/// follow the position: a plain `,` or `;` binding (brush hardness on AZERTY, where `;` sits on the +/// US comma key) must match the character typed. const POSITIONAL: [Key; 2] = [Key::Period, Key::Comma]; /// Dispatch this frame's shortcut presses in the order they arrived (⌘Z then ⌘S undoes, then @@ -236,7 +238,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) - .and_then(|p| table.iter().find(|(_, sc)| sc.logical_key == p && key_matches(sc, p, *modifiers))); + .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) } _ => None, diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 2827671a84..00f1a120a3 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -804,3 +804,23 @@ fn type_size_keys_follow_key_position_and_the_typed_selection() { assert_eq!(logged(&h), ["type.decreaseSize"]); assert_eq!(sizes(&h), [(2, base), (3, base + 2.0)], "only the selected \"He\" shrinks"); } + +/// AZERTY: `;` sits on the US comma key and `,` on the US M key. Plain `,` / `;` bindings (brush +/// hardness) must match the character typed, not the key's position, or `;` fires `,`'s command. +#[test] +fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("tools.decreaseBrushHardness".into(), ",".into()); + p.shortcuts.insert("tools.increaseBrushHardness".into(), ";".into()); + }); + let tap = |h: &mut Harness<'_, PhotocraftApp>, key, physical| { + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + tap(&mut h, egui::Key::Semicolon, egui::Key::Comma); + assert_eq!(logged(&h), ["tools.increaseBrushHardness"]); + tap(&mut h, egui::Key::Comma, egui::Key::M); + assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d14046db9d..45ef0b1528 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -792,11 +792,14 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - show_only(&mut solo.layers, &[id]); - // Its clipping base is hidden here, which would clip it away entirely (#5239). - // Clear the target itself, not only the top-level group that contains it. - if let Some(l) = solo.layer_mut(id) { - l.clipped = false; + if layer.is_some() { + show_preview_target(&mut solo.layers, id); + } else { + // Keep the previous fallback for an invalid id: render the document, with only + // top-level clipping disabled. Production callers get ids from this document. + for l in &mut solo.layers { + l.clipped = false; + } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { l.effects.enabled = false; @@ -986,6 +989,24 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { } } +/// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path +/// is detached from its clipping base because the base is hidden in this isolated render (#5239). +fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { + for l in layers { + if l.id == id { + l.clipped = false; + continue; + } + match &mut l.content { + LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { + l.clipped = false; + show_preview_target(&mut g.children, id); + } + _ => l.visible = false, + } + } +} + fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } @@ -4768,6 +4789,37 @@ mod tests { ); } + #[test] + fn nested_group_preview_detaches_every_clipped_ancestor() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + let mut middle = Layer::group("middle clipped ancestor", vec![target]); + middle.clipped = true; + let mut inner_base = Layer::raster("hidden inner clipping base", PixelFormat::RGBA8); + inner_base.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + let mut outer = Layer::group("outer clipped ancestor", vec![inner_base, middle]); + outer.clipped = true; + doc.layers.push(outer); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); + } + + #[test] + fn unknown_preview_target_keeps_the_previous_document_fallback() { + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active().unwrap(); + let (image, _) = preview_image(&st.doc, LayerId::fresh(), None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0)); + } + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index 0ed51de669..457c03c1cb 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, + type_layer: false, guide: false, spelling: None, }; diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 3dfe34dedc..6f05ed61a5 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -83,6 +83,35 @@ fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { assert!(h.ctx.data(|d| d.get_temp::(hover_id)).is_none()); } +/// #7406: hovering a font highlights its row without moving anything: the other rows keep +/// their place, the menu its width, and every row (the selected one too) takes one height. +#[test] +fn hovering_a_font_row_moves_no_other_row() { + let mut h = harness(); + // The app's theme: its hovered and selected buttons draw a stroke (egui's default style + // would hide the jump). + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run(); + open(&mut h); + let rows = |h: &Harness<'static, State>| -> Vec { (0..8).map(|i| h.get_by_label(&format!("Family {i:02}")).rect()).collect() }; + let before = rows(&h); + let width = popup_rect(&h).expect("font menu open").width(); + let tops: Vec = before.windows(2).map(|w| w[1].top() - w[0].top()).collect(); + assert!(tops.windows(2).all(|t| (t[0] - t[1]).abs() < 0.01), "rows are evenly spaced, the selected Family 00 included: {tops:?}"); + for hovered in [2, 5] { + h.hover_at(before[hovered].center()); + h.run_steps(2); + let after = rows(&h); + for (i, (b, a)) in before.iter().zip(&after).enumerate() { + if i != hovered { + assert_eq!(b, a, "hovering Family {hovered:02} moved Family {i:02}"); + } + } + assert_eq!(after[hovered].center(), before[hovered].center(), "the hovered row stays centred where it was"); + assert_eq!(popup_rect(&h).map(|r| r.width()), Some(width), "the menu keeps its width"); + } +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); @@ -210,6 +239,88 @@ fn arrows_apply_fonts_without_closing_and_enter_accepts() { assert!(popup_rect(&h).is_none()); } +/// #7407: as in Photoshop, the field keeps the focus once a font is picked (click or Enter), +/// and ↑ / ↓ on it step through the families with the menu closed. Escape ends it. +#[test] +fn arrows_on_the_field_step_through_the_families_after_a_pick() { + let mut h = harness(); + open(&mut h); + h.get_by_label("Family 03").click(); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "picking closes the menu"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "the field keeps the focus"); + let press = |h: &mut Harness<'static, State>, key: Key| { + h.key_press(key); + h.run(); + assert!(popup_rect(h).is_none(), "{key:?} leaves the menu closed"); + assert!(h.ctx.memory(|m| m.has_focus(button)), "{key:?} keeps the focus on the field"); + h.state().current.clone() + }; + assert_eq!(press(&mut h, Key::ArrowDown), "Family 04"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 05"); + assert_eq!(press(&mut h, Key::ArrowUp), "Family 04"); + // Enter in the open menu is a pick too. + open(&mut h); + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 05"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 06"); + // Escape takes the focus away; the arrows then leave the font alone. + h.key_press(Key::Escape); + h.run(); + assert!(!h.ctx.memory(|m| m.has_focus(button))); + h.key_press(Key::ArrowDown); + h.run(); + assert_eq!(h.state().current, "Family 06"); +} + +/// The first arrow right after a pick (in the very next frame) steps the font and keeps the +/// focus on the field, also between focusable widgets: the field's lock filter only holds from +/// the frame after it got the focus, and egui would read that arrow as a move to "Below". +#[test] +fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { + for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { + let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); + let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, s: &mut State| { + let _ = ui.button("Above"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); + let fams = s.families.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let _ = ui.button("Below"); + }, + state, + ); + h.run(); + open(&mut h); + if enter { + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + } else { + h.get_by_label("Family 05").click(); + } + // Frame by frame up to the pick, then the arrow in the next frame. + for _ in 0..8 { + if h.state().current == picked && popup_rect(&h).is_none() { + break; + } + h.step(); + } + assert_eq!(h.state().current, picked, "enter: {enter}"); + h.key_press(Key::ArrowDown); + h.step(); + h.run(); + assert_eq!(h.state().current, stepped, "enter: {enter}"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "enter: {enter}: the arrow moved the focus off the field"); + } +} + #[test] fn arrows_follow_the_filtered_list_and_handle_no_matches() { let mut h = harness(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d9a4ae9928..d07b8bb161 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { /// 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 { +pub(crate) 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); // A Type Mask session's text is a hidden layer; it is still the text being edited. @@ -1451,6 +1451,8 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, ) -> bool { let mut changed = false; + // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. + let mut picked = false; let search_id = ui.id().with(("font-search", salt)); let focus_id = search_id.with("focus"); // The list opens on the press, so a press-drag-release picks a font too (#2735). @@ -1508,6 +1510,7 @@ pub(crate) fn family_picker_in( } } let accept = enter && rows.iter().any(|(f, _)| *f == current); + picked |= accept; if accept { ui.data_mut(|d| d.remove::(search_id)); ui.close(); @@ -1517,7 +1520,11 @@ pub(crate) fn family_picker_in( let mut hovered: Option = None; for (f, label) in rows { - let mut button = egui::Button::selectable(f == current, label).truncate(); + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). + let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); if let Some((size, [_, h])) = preview { button = button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); @@ -1539,6 +1546,7 @@ pub(crate) fn family_picker_in( // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); changed = true; + picked = true; ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); @@ -1561,6 +1569,38 @@ pub(crate) fn family_picker_in( } let hovered_font = response.inner.unwrap_or_default(); + // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the + // focused, closed field step to the previous / next family, applying each (#7407). The lock + // filter keeps egui from using those arrows to move the focus to another widget. + let field = response.response.id; + if picked { + ui.memory_mut(|m| m.request_focus(field)); + } + let open = egui::ComboBox::is_open(ui.ctx(), field); + if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); + let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); + if down || up { + // The lock filter only holds from the frame after the field got the focus: an arrow + // right after a pick was already read by egui as a focus move, applied at the end of + // the frame. Cancel it, so the arrow only steps the font. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + // The wheel's step: a negative notch is the next family. + let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); + let rows = font_menu_rows(families, labels, ""); + if let Some(i) = rows.iter().position(|(f, _)| *f == current) + && let Some(n) = wheel_family_index(i, rows.len(), notches) + && let Some((f, _)) = rows.get(n) + && **f != *current + { + current.clone_from(f); + photocraft_text::served::request(f); + changed = true; + } + } + // The wheel over the closed button steps through the families, a notch each, like Photoshop's // options-bar font menu: no need to open the list (#3350). if !egui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index bde5e71ae3..68a2321c44 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -8,6 +8,9 @@ //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › //! Zoom with Trackpad Pinch is off (then a pinch does nothing, and Windows' Ctrl + wheel //! fractions scroll sideways like the wheel, because the fold below is skipped). +//! - A two-finger pinch on a touch screen zooms the same way, unless Preferences › Enhanced +//! Controls › Enable Gestures is off (#204): then the fingers leave the view alone, and a +//! trackpad pinch still zooms ([`pinch_factor`]). //! //! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so //! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and @@ -23,7 +26,7 @@ //! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next //! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. -use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -198,13 +201,15 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { - read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true, true) } /// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's /// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch /// in the frame the wheel turns and stops there (#5721), as scroll areas already do. -pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { +/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's +/// two-finger pinch doesn't zoom ([`pinch_factor`]). +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool, touch_gestures: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -225,7 +230,7 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: _ => None, }) .collect(); - (i.smooth_scroll_delta, i.zoom_delta(), latest_alt, latest_shift, wheel, i.content_rect().height()) + (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); let alt = match latest_alt { Some(a) => { @@ -329,6 +334,24 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: } } +/// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's +/// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences › +/// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only +/// the trackpad's own pinch events count (Windows' Ctrl + wheel pinch included: [`fold_legacy_pinch`] +/// has already turned it into `Event::Zoom`). +pub fn pinch_factor(i: &InputState, touch_gestures: bool) -> f32 { + if touch_gestures || i.multi_touch().is_none() { + return i.zoom_delta(); + } + i.events + .iter() + .filter_map(|e| match e { + Event::Zoom(f) if f.is_finite() && *f > 0.0 => Some(*f), + _ => None, + }) + .product() +} + #[cfg(test)] mod tests { use egui::TouchPhase; @@ -393,7 +416,7 @@ mod tests { let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; let mut pan = Vec2::ZERO; let mut out = ctx.run_ui(raw, |ui| { - if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth, true) { pan = d; } }); @@ -922,4 +945,62 @@ mod tests { let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, -1.0), Modifiers::SHIFT)]], true, false); assert_eq!(snaps, -1, "Line-unit notch down produces −1 step"); } + + /// Two fingers on a touch screen: down 20 points apart, spread to 40 (a ×2 pinch), lifted. + fn touch_pinch_frames() -> Vec> { + let touch = |id: u64, phase: TouchPhase, x: f32| Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(id), + phase, + pos: egui::pos2(x, 50.0), + force: None, + }; + vec![ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(egui::pos2(40.0, 50.0))], + vec![touch(1, TouchPhase::Start, 40.0), touch(2, TouchPhase::Start, 60.0)], + vec![touch(1, TouchPhase::Move, 30.0), touch(2, TouchPhase::Move, 70.0)], + vec![touch(1, TouchPhase::End, 30.0), touch(2, TouchPhase::End, 70.0)], + ] + } + + /// The total zoom [`read_with`] reports over `frames`, Enable Gestures set to `touch_gestures`. + fn touch_zoom(frames: Vec>, touch_gestures: bool) -> f32 { + let owner = Id::new("test-touch"); + let ctx = Context::default(); + configure(&ctx); + let mut zoom = 1.0; + for (n, events) in (0u32..).zip(frames) { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, owner, true, true, touch_gestures) { + zoom *= f; + } + }); + out.textures_delta.clear(); + } + zoom + } + + /// Preferences › Enhanced Controls › Enable Gestures (#204): on (the default) a two-finger + /// pinch on a touch screen zooms; off, it leaves the view alone. + #[test] + fn enable_gestures_switches_touch_screen_pinch_zoom() { + let on = touch_zoom(touch_pinch_frames(), true); + assert!((on - 2.0).abs() < 1e-4, "on: the fingers zoom ×2: {on}"); + let off = touch_zoom(touch_pinch_frames(), false); + assert_eq!(off, 1.0, "off: the fingers don't zoom"); + } + + /// Enable Gestures off only silences the touch screen: a trackpad pinch still zooms, also in a + /// frame where two fingers rest on the screen (egui's `zoom_delta` would report theirs instead). + #[test] + fn enable_gestures_off_keeps_the_trackpad_pinch() { + let zoom = touch_zoom(vec![vec![Event::Zoom(1.25)]], false); + assert!((zoom - 1.25).abs() < 1e-4, "no fingers down: {zoom}"); + let mut frames = touch_pinch_frames(); + frames[2] = vec![Event::Zoom(1.25)]; + let zoom = touch_zoom(frames, false); + assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); + } } diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 1e8abf9a67..53b6d99bd9 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (Path Selection canvas menu, #1056) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -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. 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) | +| 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). Over committed type (no edit running, `TYPE_LAYER_MENU`, #7324): the same rows without Undo, Cut, Copy, Paste, Select All and Faux Bold/Italic; the clicked type becomes the active layer first (a layer multiselection stays). The Type Mask tools get no menu. Ignore / Ignore All remain open | P1; editing and nonediting states are separate; [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. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain 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 | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Type tool menu over committed type (#7324) | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..e6d5c9591d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,8 +73,9 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. + - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `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}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) diff --git a/docs/decisions.md b/docs/decisions.md index 15519ecf74..ac44d14500 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,3 +5,30 @@ Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects. Native files persist this ID. Legacy embedded objects without an ID remain independent: identical file bytes do not prove that two objects were intended to share edits. Legacy nonempty linked paths and PSD source identifiers retain their explicit sharing relationship. PSD export keys embedded sources by contents identity as well as bytes, so independent equal-content objects stay independent. + +## 2026-10-17: keeping a replaced file's ACL and named streams (#7073, #7094, #7096) + +Saving replaces a file by renaming a new one over it, which drops everything the old file +carried. Carrying the security-relevant parts across needs platform calls that have no safe API +in `std`, and the workspace forbids `unsafe`, so each platform took a dependency rather than an +exception. + +**macOS ACLs: `exacl`, not `fclonefileat`.** The two routes that avoid `unsafe` are a macOS-only +`exacl` dependency (a safe wrapper over `acl_get_file`/`acl_set_file`) and `fclonefileat` with +the undocumented-in-`rustix` `CLONE_ACL` bit. The clone keeps the ACL, but only on APFS, and it +means writing every save as clone-then-truncate-then-write with a fallback for every other file +system — restructuring the one write path the whole app's durability rests on, to preserve an +ACL. `exacl` is a best-effort step beside the existing permission copy, it works wherever ACLs +do, and a failure cannot affect the bytes. + +**Windows: `ReplaceFileW` via `winsafe`, with a backup name.** Copying the `Zone.Identifier` +stream by hand with `std` would cover the Mark of the Web but not the DACL, and enumerating every +stream needs `FindFirstStreamW`. `ReplaceFileW` is the call Microsoft documents for the +save-through-a-temporary-file pattern and preserves the DACL, named streams, creation time and +object identifier in one step; `winsafe`'s `kernel` feature wraps it safely and pulls in nothing. + +The backup-file argument is a correctness requirement, not an option: `ReplaceFileW` without one +documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT` as leaving the replaced file deleted and the +replacement under its temporary name — a failed save that loses the user's document. With a +backup name the same failure leaves both files alone, and the one failure that does move things +(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) is unwound in `atomic/meta.rs`. diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..4457a87dde 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -199,8 +199,8 @@ what an agent can't finish alone. 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 - (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer + copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. - **Estimate:** 150–250 h. @@ -270,6 +270,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index e7954a5b4d..1ef76a45b8 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 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 24 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,9 +179,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 25 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 24 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -192,7 +192,7 @@ Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_ | 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; 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-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); over committed type the Type tool opens the type layer menu (#7324); 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); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | @@ -207,7 +207,7 @@ Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_ | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | | UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | done | [#217](https://github.com/storytold/photocraft/issues/217) | stamp_cmds.rs layer.stampVisible (Cmd+Alt+Shift+E) merges every visible layer into a new layer above the active one and keeps the originals; layer.stampDown (Cmd+Alt+E) too. As in Photoshop they have no menu item of their own: Alt + Layer > Merge Visible / Merge Down / Merge Layers run them (stamp_cmds::alt_variant, menus.rs); stamp_tests.rs alt_click_on_merge_items_stamps and cmd_alt_shift_e_stamps_visible_and_can_be_rebound | -| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard | +| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind | ## Type diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..3eca518c27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -167,7 +167,7 @@ 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: 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 | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), 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) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 0000000000..dd791ea1f5 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,60 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/photocraft. The file path is the URL: +`troubleshooting/taskbar-turns-black.md` → `/docs/photocraft/troubleshooting/taskbar-turns-black`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using PhotoCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install PhotoCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "PhotoCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths read `File › Open…`. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Export an image as PNG or JPEG + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.7.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/photocraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 0000000000..e7bcfb70ed --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 0000000000..c50180a390 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,10 @@ +--- +title: FAQ +description: Short answers about PhotoCraft's price, Photoshop files and how it compares with Photoshop. +order: 40 +verified: 0.8.2 +--- + +- [Is PhotoCraft free?](is-photocraft-free.md) +- [Can PhotoCraft open my Photoshop files?](open-photoshop-files.md) +- [Can PhotoCraft replace Photoshop?](replace-photoshop.md) diff --git a/docs/user/faq/is-photocraft-free.md b/docs/user/faq/is-photocraft-free.md new file mode 100644 index 0000000000..dd9946540a --- /dev/null +++ b/docs/user/faq/is-photocraft-free.md @@ -0,0 +1,14 @@ +--- +title: Is PhotoCraft free? +description: PhotoCraft is free and open source under the MIT or Apache 2.0 license, and works offline. +order: 10 +verified: 0.8.2 +--- + +Yes. PhotoCraft is one of the Crafting Apps, which the project describes as free, open-source creative tools. The code is licensed under MIT or Apache 2.0, your choice, and is on [GitHub](https://github.com/storytold/photocraft). + +PhotoCraft works offline. Its smart selection tools run on your computer, with no cloud and no account. + +The ArtCraft logos in the repository are not open source. + +See [Install PhotoCraft](../getting-started/install.md) to download it. diff --git a/docs/user/faq/open-photoshop-files.md b/docs/user/faq/open-photoshop-files.md new file mode 100644 index 0000000000..06fbe367bc --- /dev/null +++ b/docs/user/faq/open-photoshop-files.md @@ -0,0 +1,16 @@ +--- +title: Can PhotoCraft open my Photoshop files? +description: PhotoCraft opens and saves layered PSD and PSB files and keeps what it does not understand yet. +order: 20 +verified: 0.8.2 +--- + +Yes. PhotoCraft opens, edits and saves layered PSD and PSB files, including 16-bit, 32-bit, CMYK and Lab documents. + +When it saves the file again, the picture renders the same for 307 of the 309 files in the psd-tools test set the project checks against. Parts PhotoCraft does not model yet, such as raw blocks and descriptors, are carried over instead of being dropped. A re-saved file is not byte for byte the same as the original, because PhotoCraft rewrites image resources, layer records and the composite. + +Text layers stay editable. In version 0.7.0 some of them moved when you started editing them; 0.8.2 fixes that, see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +Results vary from file to file. If a PSD does not open or looks wrong, search the issue tracker or report it with the file. + +PhotoCraft also opens layered TIFF, Paint.NET, OpenRaster, SVG, Krita and Affinity files (Krita and Affinity read only), and many flat formats. The full list is in [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/faq/replace-photoshop.md b/docs/user/faq/replace-photoshop.md new file mode 100644 index 0000000000..56027f2706 --- /dev/null +++ b/docs/user/faq/replace-photoshop.md @@ -0,0 +1,12 @@ +--- +title: Can PhotoCraft replace Photoshop? +description: Not yet. PhotoCraft is in early alpha and is not ready for daily professional work, but it follows Photoshop's menus and shortcuts. +order: 30 +verified: 0.9.0 +--- + +Not yet. PhotoCraft is in early alpha. Much of Photoshop's feature surface exists in some form, but the project says plainly that it is not yet a Photoshop replacement for daily professional work. + +The biggest gaps the project lists are AI and generative features, tools Photoshop has that PhotoCraft does not yet, depth in typography and professional workflows, and plug-in compatibility. Every Photoshop menu item is connected to a command, but that only shows the menu is wired up. Having a tool or command is not the same as matching Photoshop's behaviour. + +What PhotoCraft does well already: layers, masks, adjustment layers, layer styles, type, vector shapes, brushes and live-preview filters, in a menu and shortcut layout that follows Photoshop. See [PhotoCraft](../index.md) for an overview, and the [project roadmap](https://github.com/storytold/photocraft/blob/main/ROADMAP.md) for what comes next. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 0000000000..2383e17f0b --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,21 @@ +--- +title: Getting started +description: Install PhotoCraft and open your first image. +order: 10 +verified: 0.8.2 +--- + +Install PhotoCraft, then open an image with **File › Open…**. The panels you use most are **Layers**, **Properties** and **History**. + +1. [Install PhotoCraft](install.md). +2. Start PhotoCraft and choose **File › Open…**. To start from nothing, choose **File › New…** and click **Create**. +3. Pick the **Horizontal Type Tool**, a selection tool or an adjustment, and work on the image. Every step is listed in the **History** panel and can be undone. +4. Keep your layers with **File › Save As…** (PSD or PhotoCraft), or make a flat copy with **File › Export › Export As…**. + +The guides walk through each of these: + +- [Open and save files](../guides/open-and-save-files.md) +- [Adjust colors without changing your pixels](../guides/adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](../guides/select-and-mask.md) +- [Add and style text](../guides/add-and-style-text.md) +- [Export an image](../guides/export-an-image.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 0000000000..975f7f3b71 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,59 @@ +--- +title: Install PhotoCraft +description: Download PhotoCraft for Windows 10 or 11, macOS or Linux from getartcraft.com or GitHub, or install it with ArtCraft Launcher. +order: 10 +verified: 0.8.2 +--- + +The easiest way is the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). You can also install it with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps), which lists PhotoCraft. For a specific build or file format, take it from the [latest release](https://github.com/storytold/photocraft/releases/latest) on GitHub. In the file names below, `` stands for the version number, for example `0.8.2`. + +## Windows + +PhotoCraft needs Windows 10 or later. On Windows 7 and 8 the installer stops with "PhotoCraft needs Windows 10 or later." + +1. Open the [latest release](https://github.com/storytold/photocraft/releases/latest). +2. Download `photocraft--windows-x64.msi`. It fits most PCs (64-bit Intel and AMD). +3. Run the file and follow the installer. + +To run without installing, download `photocraft--windows-x64-portable.zip` instead and unzip it. + +| Your PC | Installer | Portable | +|------------------------------------|------------------------------------------|---------------------------------------------------| +| 64-bit Intel or AMD | `photocraft--windows-x64.msi` | `photocraft--windows-x64-portable.zip` | +| Arm (Snapdragon and other Arm PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | +| 32-bit | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | + +The installers and executables are code-signed. + +## macOS + +1. Download `photocraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized by Apple. +2. Open it and drag PhotoCraft to Applications. + +## Linux + +Pick one file from the release. Use `x86_64` for Intel and AMD, or `aarch64` for Arm. + +| Format | File | Notes | +|---------------------|----------------------------------------------|------------------------------------------------------------------------------------| +| AppImage | `photocraft--linux-x86_64.AppImage` | Runs anywhere and needs no install. | +| Debian and Ubuntu | `photocraft--linux-x86_64.deb` | | +| Fedora and openSUSE | `photocraft--linux-x86_64.rpm` | | +| Flatpak | `photocraft--linux-x86_64.flatpak` | Run `flatpak install --user `, then `flatpak run ai.storyteller.photocraft`. | +| Tarball | `photocraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +The first time you run the AppImage, it adds its icon and menu entry under `~/.local/share`. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that. For RISC-V, take the `riscv64` file: `photocraft--linux-riscv64.tar.gz`, `.deb` or `.rpm`. The RISC-V tarball needs glibc 2.39 or newer. + +On a Wayland session, files dropped on the PhotoCraft window do not open yet. Use **File › Open…** instead, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. PhotoCraft shows the right command for your install in a notice until you dismiss it. + +## FreeBSD and the web + +FreeBSD 14 (x86_64) has a tarball, `photocraft--freebsd-x86_64.tar.gz`, laid out like `/usr/local`. The web version comes as `photocraft-web-.zip`, a static site you host on any web server. It runs in a modern browser. + +## Check your download + +`SHA256SUMS.txt` on the release lists a checksum for every file. + +## Next step + +Open an image: [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/guides/add-and-style-text.md b/docs/user/guides/add-and-style-text.md new file mode 100644 index 0000000000..71dcd7db97 --- /dev/null +++ b/docs/user/guides/add-and-style-text.md @@ -0,0 +1,36 @@ +--- +title: Add and style text +description: Add point or paragraph text with the Horizontal Type Tool in PhotoCraft, then give it a drop shadow or glow. +order: 40 +verified: 0.8.2 +--- + +Pick the **Horizontal Type Tool** (shortcut `T`), click on the canvas for a line of text or drag for a text box, and type. Text stays editable, so you can change the words and the style later. + +## Add text + +1. Choose the **Horizontal Type Tool** in the toolbox, or press `T`. +2. Click the canvas to start a line of text (point text), or drag a rectangle to start a block of text (paragraph text). +3. Type. The text is edited right on the canvas. +4. Set the font, size, leading, tracking, alignment and color with the character and paragraph controls. Each typing session is one **Edit Type** step in the **History** panel. + +To switch between the two kinds later, use **Type › Convert to Paragraph Text** or **Type › Convert to Point Text**. + +## Edit the text later + +Pick the **Horizontal Type Tool** and click the text on the canvas. Text from a PSD that was made in Photoshop stays editable too. If PSD text jumps when you start editing it, update to 0.8.2; see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +To start new text on top of an existing text layer instead of editing it, hold `Shift` while you click. + +## Add a layer style + +1. Click the text layer in the **Layers** panel. +2. Choose **Layer › Layer Style**, then an effect such as **Drop Shadow…**, **Outer Glow…**, **Stroke…** or **Bevel & Emboss…**. The **Layer Style** dialog opens. +3. Adjust the effect and confirm. + +The other effects in the menu are **Inner Shadow…**, **Inner Glow…**, **Satin…**, **Color Overlay…**, **Gradient Overlay…** and **Pattern Overlay…**. **Layer › Layer Style** also has **Copy Layer Style** and **Paste Layer Style** to reuse a style on another layer, and **Hide All Effects**. + +## Turn text into shapes or pixels + +- **Type › Convert to Shape** makes vector shapes from the letters. +- **Type › Rasterize Type Layer** turns the text into pixels. After that you can no longer edit the words. diff --git a/docs/user/guides/adjust-colors-with-adjustment-layers.md b/docs/user/guides/adjust-colors-with-adjustment-layers.md new file mode 100644 index 0000000000..500ff3521c --- /dev/null +++ b/docs/user/guides/adjust-colors-with-adjustment-layers.md @@ -0,0 +1,32 @@ +--- +title: Adjust colors without changing your pixels +description: Add a Curves, Levels or other adjustment layer in PhotoCraft so you can change or switch it off later. +order: 20 +verified: 0.8.2 +--- + +Use **Layer › New Adjustment Layer** and pick an adjustment. The change sits on its own layer above your image, so your original pixels stay as they were and you can edit or hide the adjustment later. + +## Add an adjustment layer + +1. Open your image and click the layer the adjustment should sit above in the **Layers** panel. +2. Choose **Layer › New Adjustment Layer**, then an adjustment such as **Curves…**, **Levels…**, **Vibrance…** or **Hue/Saturation…**. +3. Change the settings in the **Properties** panel, which shows the settings of the new layer. + +The same list is behind the button at the bottom of the **Layers** panel, labelled **Create new fill or adjustment layer** when you hover over it. + +The adjustments in the menu are **Brightness/Contrast**, **Levels**, **Curves**, **Exposure**, **Vibrance**, **Hue/Saturation**, **Color Balance**, **Black & White**, **Photo Filter**, **Channel Mixer**, **Color Lookup**, **Invert**, **Posterize**, **Threshold**, **Gradient Map** and **Selective Color**. + +## Limit it to one area + +1. Select the area first, for example with the **Elliptical Marquee Tool**. See [Select part of an image and mask it](select-and-mask.md). +2. Add the adjustment layer as above. +3. With the adjustment layer active, choose **Layer › Layer Mask › Reveal Selection**. The adjustment now shows only inside the selection. + +## Change it later + +Click the adjustment layer in the **Layers** panel and edit its settings in **Properties**. Every step you take is also listed in the **History** panel. + +## Apply an adjustment straight to the pixels + +If you do not need to change it later, use **Image › Adjustments**, for example **Image › Adjustments › Curves…**. These change the pixels of the layer directly and are not kept as a separate layer. diff --git a/docs/user/guides/export-an-image.md b/docs/user/guides/export-an-image.md new file mode 100644 index 0000000000..82d5c5a4e4 --- /dev/null +++ b/docs/user/guides/export-an-image.md @@ -0,0 +1,38 @@ +--- +title: Export an image +description: Export a PNG, JPG, WebP, TIFF or TGA from PhotoCraft with a preview, a size estimate and a scale. +order: 50 +verified: 0.8.2 +--- + +Choose **File › Export › Export As…**, set the format, and click **Export**. The dialog shows a preview and an estimate of the file size before you save. Your layered document is not changed. + +## Export with settings + +1. Choose **File › Export › Export As…**. +2. Under **File Settings**, pick a **Format**: **PNG**, **JPG**, **WebP**, **TIFF** or **TGA**. +3. Set the options for that format: + - **JPG** and lossy **WebP** have a **Quality** slider from 1 to 100. + - **WebP** also has a **Lossless** checkbox. + - **TGA** has **Resolution**: **Auto**, **24 bits/pixel** or **32 bits/pixel**. + - **Transparency** keeps transparent areas. JPG has no transparency, so this checkbox is not shown for JPG and the image is flattened. + - **Metadata** is **None** or **All**. +4. Under **Image Size**, set **Scale** in percent. The size in pixels shows below it. +5. Check the preview on the right. The line under it shows the format and the estimated file size. +6. Click **Export**, then choose a name and a place for the file. + +PhotoCraft shows "Exported" with the file path and size in the status bar when it is done. + +## Export in one step + +**File › Export › Quick Export as PNG** exports without asking about settings. It uses the format, quality, metadata and location you set in **File › Export › Export Preferences…**. + +## Export layers + +Choose **Layer › Export As…** (or **Layer › Quick Export as PNG**) to export only the active layer. + +With several layers or artboards selected in the **Layers** panel, both commands export each one as its own file. You choose a name and place once. The first file gets that name, and the others get the same name with a number and the layer name added. + +## Keep your layers + +Exporting makes a flat copy. To keep the layers, use **File › Save As…** with **PSD Document** or **PhotoCraft**; see [Open and save files](open-and-save-files.md). diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 0000000000..a493dc2c8a --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,14 @@ +--- +title: Guides +description: Step-by-step guides for the everyday tasks in PhotoCraft. +order: 20 +verified: 0.8.2 +--- + +- [Open and save files](open-and-save-files.md) +- [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](select-and-mask.md) +- [Add and style text](add-and-style-text.md) +- [Export an image](export-an-image.md) + +Menu paths are written like **File › Open…**. Where a shortcut is shown, `Cmd` is the Ctrl key on Windows and Linux. diff --git a/docs/user/guides/open-and-save-files.md b/docs/user/guides/open-and-save-files.md new file mode 100644 index 0000000000..0c2b4306af --- /dev/null +++ b/docs/user/guides/open-and-save-files.md @@ -0,0 +1,32 @@ +--- +title: Open and save files +description: Open a PSD or other image in PhotoCraft, and save it with its layers or as a flat copy. +order: 10 +verified: 0.8.2 +--- + +Open a file with **File › Open…** and keep your layers with **File › Save As…**, choosing **PSD Document** or **PhotoCraft** as the type. Formats such as PNG and JPEG are flat: they hold one image with no layers. + +## Open a file + +1. Choose **File › Open…**. +2. Pick your file. The type menu in the dialog offers **All Formats**, **PhotoCraft**, **OpenRaster**, **Krita**, **SVG** and **Paint.NET**. +3. Confirm in the dialog. + +You can also reopen a recent file from **File › Open Recent**. On Linux under Wayland, dropping a file on the window does not work yet; see [Install PhotoCraft](../getting-started/install.md). + +PhotoCraft opens PSD and PSB (including 16-bit, 32-bit, CMYK and Lab files), layered TIFF, Paint.NET (`.pdn`), OpenRaster (`.ora`), SVG, and the flat formats PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, PCX, OpenEXR, Radiance HDR and FITS. Krita files (`.kra`) open as one image layer made from the picture Krita saved, without Krita's layers. HEIC and HEIF photos from iPhone and Mac open too, read only, and so do camera raw files such as DNG, CR2, CR3, NEF and ARW. It can also open Affinity files (`.af`, `.afphoto`, `.afdesign`, `.afpub`), read only; PhotoCraft cannot save Affinity files. Anything it cannot match exactly, such as layer effects and adjustments, is approximated or left out and PhotoCraft shows a warning. + +## Save with layers + +1. Choose **File › Save As…**. +2. Pick **PSD Document** (`.psd` or `.psb`) to share the file with Photoshop, or **PhotoCraft** (`.pcraft`) for PhotoCraft's own format. +3. Name the file and save. + +After that, **File › Save** saves into the same file. **File › Save a Copy…** saves a copy under a new name. While a large file saves, the status bar and the tab title show "Saving" with a percentage. + +The **Save As…** dialog also lists OpenRaster, PNG, JPEG, WebP, TIFF, Targa, OpenEXR and FITS. **File › Save** on an opened JPEG keeps its quality, and on a WebP keeps it lossy or lossless. Check the file extension in the name box: PhotoCraft writes the format that the extension names. If you want a flat PNG or JPEG with a size and quality preview, use [Export an image](export-an-image.md) instead. + +## Good to know + +Saving a PSD again keeps the layers and the rendered result, but the file is not byte for byte the same as the one you opened. PhotoCraft rewrites image resources, layer records and the composite. It keeps parts it does not model yet instead of dropping them. diff --git a/docs/user/guides/select-and-mask.md b/docs/user/guides/select-and-mask.md new file mode 100644 index 0000000000..50f78b7685 --- /dev/null +++ b/docs/user/guides/select-and-mask.md @@ -0,0 +1,40 @@ +--- +title: Select part of an image and mask it +description: Make a selection with the marquee tools or Select Subject, soften its edge, and turn it into a layer mask in PhotoCraft. +order: 30 +verified: 0.8.2 +--- + +Pick a selection tool, drag around the area, then choose **Layer › Layer Mask › Reveal Selection** to show only that area. A mask hides part of a layer without erasing any pixels. + +## Make a selection + +Use one of these: + +- **Elliptical Marquee Tool** or **Rectangular Marquee Tool** for simple shapes. Drag on the canvas. +- **Quick Selection Tool**, **Magic Wand Tool** or **Lasso Tool** to follow the edges of an object. +- **Select › Subject** to let PhotoCraft pick the main subject. The **Quick Selection Tool** and **Object Selection Tool** also have a **Select Subject** button in the options bar. If nothing is found, PhotoCraft says "No subject found" and suggests the Object Selection or Lasso tool. + +Smart selection runs on your computer. It needs no account and no cloud. + +## Adjust the selection + +- **Select › Inverse Selection** swaps the selected and unselected areas. +- **Select › Modify** has **Border…**, **Smooth…**, **Expand…**, **Contract…** and **Feather…**. Feather softens the edge. +- **Select › Select and Mask…** refines an edge, for example around hair. +- **Select › Deselect** removes the selection. **Select › Reselect** brings back the last one. + +## Turn the selection into a mask + +1. Click the layer you want to mask in the **Layers** panel. +2. Choose **Layer › Layer Mask › Reveal Selection** to show only the selected area, or **Hide Selection** to hide it. + +To start with the whole layer visible or hidden and paint the mask yourself, use **Layer › Layer Mask › Reveal All** or **Hide All**. + +## Remove or apply a mask + +- **Layer › Layer Mask › Delete** removes the mask. +- **Layer › Layer Mask › Apply** applies the mask to the layer's pixels. +- **Layer › Layer Mask › Disable Layer Mask** (or **Enable Layer Mask**) switches the mask off and on. + +To limit a color adjustment to the selection, see [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md). diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..29e229c0bd --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +title: PhotoCraft +description: PhotoCraft is a free, open-source image editor with layers, masks, type and real PSD files, built as an alternative to Adobe Photoshop. +verified: 0.8.2 +--- + +PhotoCraft is a free, open-source image editor with layers, masks, adjustment layers, layer styles, type, vector shapes and brushes. It is an alternative to Adobe Photoshop: the menus, panels, tools and shortcuts follow Photoshop's layout, so Photoshop users find things where they expect them. + +It is a native app written in Rust that draws the canvas on your graphics card, works offline, and opens and saves layered PSD files. It runs on macOS, Windows, Linux and FreeBSD, and a web version runs in the browser. PhotoCraft is in early alpha: much of Photoshop's feature surface exists, but it is not yet a Photoshop replacement for daily professional work. See [Can PhotoCraft replace Photoshop?](faq/replace-photoshop.md). + +## Start here + +- [Install PhotoCraft](getting-started/install.md) +- [Your first edit](getting-started/index.md) +- [Guides: open, adjust, select, type, export](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Part of the Craft App Suite + +PhotoCraft is one of the Crafting Apps from the ArtCraft team, next to VectorCraft, FilmCraft and the other Craft apps. You can install and update them from [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/photocraft/issues). In PhotoCraft, **Help › Report an Issue…** opens the same issue tracker. diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 0000000000..ee8bc860ee --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,19 @@ +--- +title: Get help with a problem +description: How to ask for help or file a PhotoCraft bug so it can be fixed, and what to include. +order: 30 +verified: 0.8.2 +--- + +Open **Help › Report an Issue…**. It takes you to the [PhotoCraft issue tracker](https://github.com/storytold/photocraft/issues) on GitHub. You can also ask in [Discord](https://discord.gg/artcraft). + +## What to include + +1. What you did, step by step, and what you expected. +2. What happened instead. A screenshot or a short video helps. +3. Your system details. Choose **Help › System Info…**, click **Copy**, and paste the text into your message. It lists the PhotoCraft version, your operating system and processor, and your graphics adapter. +4. The file, if one file causes it. Zip a PSD or other file and attach it. Leave it out if it contains anything private. + +## Before you post + +Search the tracker for your problem first. If someone already reported it, add your details there instead of opening a new issue. For known problems, see the other pages in [Troubleshooting](index.md). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 0000000000..4785ae412b --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,11 @@ +--- +title: Troubleshooting +description: Fixes and workarounds for known PhotoCraft problems. +order: 30 +verified: 0.8.2 +--- + +- [PhotoCraft does not start](photocraft-does-not-start.md) +- [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) +- [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/photocraft-does-not-start.md b/docs/user/troubleshooting/photocraft-does-not-start.md new file mode 100644 index 0000000000..549f6bd65d --- /dev/null +++ b/docs/user/troubleshooting/photocraft-does-not-start.md @@ -0,0 +1,58 @@ +--- +title: PhotoCraft does not start +description: PhotoCraft closes or crashes at startup on Windows PCs with Intel graphics (it uses DirectX 12 and picks a safer graphics mode after a crash) or on older Intel Macs (0.8.2 fixes one cause; no fix yet for Macs patched with OpenCore Legacy Patcher). +order: 30 +verified: 0.8.2 +--- + +On Windows, PhotoCraft starts Intel graphics on DirectX 12. If PhotoCraft closed while it was starting, start it again: it picks a safer graphics mode after a crash. On an older Intel Mac that shows a `Device(Lost)` error, update to 0.8.2, which fixes one cause; on Macs patched with OpenCore Legacy Patcher there is no fix yet. + +## Symptoms + +- Windows with Intel graphics (for example Intel UHD Graphics 630): PhotoCraft closes or crashes right after you start it. Windows Event Viewer shows an application error in `igvk64.dll` with exception `0xc0000005` ([issue 2320](https://github.com/storytold/photocraft/issues/2320), [issue 4](https://github.com/storytold/photocraft/issues/4)). +- Intel Mac: the app icon bounces in the Dock and the app disappears. Started from Terminal, it prints `Error: Wgpu(RequestDeviceError(RequestDeviceError { inner: Core(Device(Lost)) }))` ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). + +## Fix + +### Windows + +1. Use PhotoCraft 0.7.0 or later. Older builds do not have the steps below. +2. Start PhotoCraft again. Double-clicking its icon or a `.pcraft` file works. PhotoCraft notes that the last start did not finish and uses the next safer graphics mode this time: + - A start on Vulkan is followed by one on DirectX 12. + - A start on DirectX 12 is followed by one on the CPU. The CPU mode does not depend on the graphics driver. +3. If it started, nothing more to do. PhotoCraft remembers the mode that worked. You can see or reset it under **Edit › Preferences › Performance…** (on macOS, **PhotoCraft › Settings › Performance…**). + +If PhotoCraft still closes on every start, force DirectX 12 from PowerShell: + +1. Open PowerShell in the folder that contains `photocraft.exe`. +2. Run these three lines: + + $env:WGPU_BACKEND = "dx12" + & .\photocraft.exe + Remove-Item Env:WGPU_BACKEND + +The last line clears the setting so later launches use the default again. You need the first two lines each time you start PhotoCraft this way. While `WGPU_BACKEND` is set, PhotoCraft uses it and does not step down to a safer mode by itself ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). + +### Intel Mac + +1. Update to PhotoCraft 0.8.2 or later. On Intel Macs it skips a set of graphics checks that macOS could not compile on older Intel graphics, such as Intel Iris Pro on macOS Monterey ([issue 3107](https://github.com/storytold/photocraft/issues/3107)). +2. Start PhotoCraft again. + +If it still fails, there is no fix yet. This is the case reported on Macs patched with OpenCore Legacy Patcher, where setting `WGPU_BACKEND`, `--safe-gpu` and `PHOTOCRAFT_CPU_CANVAS=1` did not change the error ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). Add your Mac model, macOS version and PhotoCraft version to that issue. + +## Why this happens + +On Windows, PhotoCraft draws with your graphics driver. Intel's Vulkan driver (`igvk64.dll`) crashed PhotoCraft on a UHD 630 PC with PhotoCraft 0.6.0, and the DirectX 12 setting above avoided the crash ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). Since 0.7.0 PhotoCraft prefers DirectX 12 on Intel graphics. Before it opens the window, it writes a small file called `gpu-starting.json` in its settings folder and deletes it once the first frames are drawn. A start that crashed leaves the file behind, and the next start reads it and moves to the next safer mode. + +## Still stuck? + +See [Get help with a problem](get-help.md). Say which graphics card you have, which PhotoCraft version you use and how you started it. Add one of these: + +- If you can open PhotoCraft: the text from **Help › System Info…**. +- If PhotoCraft does not open at all: the log file `photocraft.log`, plus the faulting module and exception code from Event Viewer on Windows. PhotoCraft keeps it in a `logs` folder inside its settings folder: + - Windows: `%APPDATA%\Photocraft\logs\photocraft.log` + - macOS: `~/Library/Application Support/Photocraft/logs/photocraft.log` + - Linux: `~/.config/photocraft/logs/photocraft.log` + - Portable copy (a `portable.txt` file next to the program): the `PhotoCraftData\logs` folder next to the program. + + Every start moves the previous log to `photocraft.1.log`. If you already started PhotoCraft again after the crash, attach `photocraft.1.log` too, because it holds the run that crashed. diff --git a/docs/user/troubleshooting/taskbar-turns-black.md b/docs/user/troubleshooting/taskbar-turns-black.md new file mode 100644 index 0000000000..0c8f5b733e --- /dev/null +++ b/docs/user/troubleshooting/taskbar-turns-black.md @@ -0,0 +1,29 @@ +--- +title: The Windows taskbar turns black when PhotoCraft is maximized +description: On Windows 11, PhotoCraft 0.7.0 can turn the taskbar black or hide it when maximized. PhotoCraft 0.8.2 fixes this; update. +order: 10 +verified: 0.8.2 +--- + +Update PhotoCraft. Version 0.7.0 on Windows 11 could turn the taskbar black or hide it while the window was maximized. PhotoCraft 0.8.2 includes the fix. + +## Symptoms + +- You maximize the PhotoCraft window, or switch it to full screen, and the Windows taskbar turns completely black or is hidden. +- After you resize the window and maximize it again, a black bar the height of the taskbar stays at the bottom of the screen. + +## Fix + +1. Check your version with **Help › System Info…**, which starts with "PhotoCraft" and the version. +2. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). See [Install PhotoCraft](../getting-started/install.md). +3. Start PhotoCraft again and maximize the window. + +A user who saw the black taskbar on 0.7.0 confirmed that it is gone in 0.8.2 ([issue 3864](https://github.com/storytold/photocraft/issues/3864)). + +## Why this happens + +PhotoCraft draws its own title bar. When such a window was maximized, Windows sized it to cover the whole screen, taskbar included, took it for a full-screen app and dropped the taskbar's background. The fix keeps a maximized PhotoCraft window clear of the taskbar ([issue 3144](https://github.com/storytold/photocraft/issues/3144), [pull request 3308](https://github.com/storytold/photocraft/pull/3308)). + +## Still stuck? + +If you still see it on 0.8.2, add your Windows version and PhotoCraft version to [issue 3864](https://github.com/storytold/photocraft/issues/3864), or ask in [Discord](https://discord.gg/artcraft). As a test, you can switch on **System title bar** under **Edit › Preferences › Interface…**, click **Apply** and restart PhotoCraft; Windows then draws the title bar. See [Get help with a problem](get-help.md) for what to include. diff --git a/docs/user/troubleshooting/text-moves-when-editing.md b/docs/user/troubleshooting/text-moves-when-editing.md new file mode 100644 index 0000000000..7130c8c7cf --- /dev/null +++ b/docs/user/troubleshooting/text-moves-when-editing.md @@ -0,0 +1,29 @@ +--- +title: Text moves when I start editing a PSD text layer +description: In PhotoCraft 0.7.0, a text layer from a Photoshop file can jump when you click it with the Type tool. PhotoCraft 0.8.2 fixes the known causes; update. +order: 20 +verified: 0.8.2 +--- + +Update PhotoCraft. In version 0.7.0, a text layer from a Photoshop file could jump or disappear when you started editing it. PhotoCraft 0.8.2 reads the text's styles and position the way Photoshop saved them, so the text stays where it was. + +## Symptoms + +- You open a PSD that was made in Photoshop and click one of its text layers with the **Horizontal Type Tool**. The text shifts, often to the left, as soon as editing starts. +- Centered text may show as left-aligned in the options bar. +- In some files, text that you click can disappear. + +## Fix + +1. Check your version with **Help › System Info…**. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). +2. Open the PSD again and click the text layer with the **Horizontal Type Tool**. + +If you already saved the file in 0.7.0 after the text moved, the moved text is now in the file. Go back to your original PSD if you still have it. + +## Why this happens + +Clicking a PSD text layer with the Type tool starts editing it, and PhotoCraft then lays the text out itself instead of showing the pixels Photoshop saved. PhotoCraft 0.7.0 read the alignment and size of each run of text only from Photoshop's default styles, and ignored part of the text position that recent Photoshop versions store. Both are fixed in 0.8.2 ([issue 3795](https://github.com/storytold/photocraft/issues/3795), [pull request 3819](https://github.com/storytold/photocraft/pull/3819)). + +## Still stuck? + +If text still moves in 0.8.2, keep a copy of the original with **File › Save a Copy…**, then report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with the PSD (zipped) and the Photoshop version that saved it. You can also ask in [Discord](https://discord.gg/artcraft). See [Get help with a problem](get-help.md). diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..7eff90c3ca 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; 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" +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); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9" @@ -164,4 +164,4 @@ id = "UI-217-23" target = "Layer clipboard: copy/paste whole layers (masks, styles, type) between documents" status = "partial" issue = 217 -note = "layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard" +note = "Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind"