From 12c43aaa4933100f1131cf787a1e8db8d239bff6 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 07:51:28 +0530 Subject: [PATCH 01/28] Save for Web: WebP, lossy at its Quality or lossless (part of #6391, #2415) Save for Web (Legacy) gains a WebP format: lossy through our VP8 encoder at the dialog's 0-100 Quality, or lossless (new `lossless` param). Translucent pixels keep their alpha as in PNG-24 (matte when Transparency is off); Embed Color Profile and the Metadata choice apply as for JPEG. The lossy preview decodes the file so its artefacts show; 4-Up halves a lossy quality. AVIF and JPEG XL from #6391 are not included. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 16 +++---- crates/engine/src/web_cmds.rs | 74 +++++++++++++++++++++++------ crates/engine/src/web_cmds/tests.rs | 64 ++++++++++++++++++++++++- crates/ui-egui/src/file_ui.rs | 47 ++++++++++++++++-- docs/file-format-parity.md | 5 +- 5 files changed, 176 insertions(+), 30 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..830ed51a36 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -796,7 +796,7 @@ mod tests { app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap(); let _ = invoke(&mut app, "file.export.quickExportAsPng"); } - for format in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] { dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format})); } app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); @@ -809,13 +809,13 @@ mod tests { let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect(); // The extension each save above should suggest, in order. let want = [ - "psd", // Save As untitled - "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng - "psd", // Save a Copy - "png jpg webp tif tga", // Export As - "png jpg gif webp", // Quick Export - "gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices - "csv pcpresets", // Measurement Log, Export Presets + "psd", // Save As untitled + "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng + "psd", // Save a Copy + "png jpg webp tif tga", // Export As + "png jpg gif webp", // Quick Export + "gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices + "csv pcpresets", // Measurement Log, Export Presets ] .join(" "); assert_eq!(exts, want.split(' ').collect::>()); diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..3fca7aa8ae 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -38,6 +38,9 @@ pub enum WebFormat { Png24, Jpeg, Wbmp, + /// WebP, lossy (our VP8 encoder) or lossless. Not in Photoshop's legacy dialog, but the + /// format people prepare web images in now (#2415, #6391). + Webp, } impl WebFormat { @@ -48,6 +51,7 @@ impl WebFormat { "png24" | "png-24" | "png" | "png32" => WebFormat::Png24, "jpeg" | "jpg" => WebFormat::Jpeg, "wbmp" => WebFormat::Wbmp, + "webp" => WebFormat::Webp, _ => return None, }) } @@ -57,6 +61,7 @@ impl WebFormat { WebFormat::Png8 | WebFormat::Png24 => "png", WebFormat::Jpeg => "jpg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", } } pub fn indexed(self) -> bool { @@ -93,8 +98,10 @@ pub struct WebSettings { /// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the /// web-safe cube. pub web_snap: f32, - /// JPEG quality 0..=100. + /// JPEG and lossy WebP quality 0..=100. pub quality: u8, + /// WebP: lossless instead of lossy at `quality`. + pub lossless: bool, pub progressive: bool, pub optimized: bool, pub embed_icc: bool, @@ -117,6 +124,7 @@ impl Default for WebSettings { interlaced: false, web_snap: 0.0, quality: 60, + lossless: false, progressive: false, optimized: true, embed_icc: false, @@ -148,7 +156,7 @@ impl WebSettings { } let s = |k: &str| p.get(k).and_then(Value::as_str); if let Some(f) = s("format") { - st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp)")))?; + st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp|webp)")))?; } if let Some(k) = s("palette") { st.palette = match k { @@ -184,6 +192,7 @@ impl WebSettings { for (k, slot) in [ ("interlaced", &mut st.interlaced), ("progressive", &mut st.progressive), + ("lossless", &mut st.lossless), ("optimized", &mut st.optimized), ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), @@ -241,6 +250,7 @@ impl WebSettings { WebFormat::Png24 => "png24", WebFormat::Jpeg => "jpeg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", }; let palette = match self.palette { PaletteKind::Exact => "exact", @@ -265,7 +275,7 @@ impl WebSettings { WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); - let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); + let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "lossless": self.lossless, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); if let Some(table) = &self.color_table { p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); } @@ -429,21 +439,34 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let mut color_table = Vec::new(); let mut transparent_index = None; let mut indices = Vec::new(); + // True colour (PNG-24, WebP): straight RGBA when transparency is on and a pixel is + // translucent, else RGB over the matte. + let true_color = || { + let alpha = st.transparency && region().any(|p| p[3] < 1.0); + let k = if alpha { 4 } else { 3 }; + let mut data = Vec::with_capacity(w * h * k); + for p in region() { + let q = if alpha { *p } else { over(p) }; + data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); + } + let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; + (data, layout) + }; + let rgba = |data: &[u8], layout: photocraft_codecs::ChannelLayout| -> Vec { + if layout == photocraft_codecs::ChannelLayout::Rgba { + data.to_vec() + } else { + data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() + } + }; + let meta = || photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; let (bytes, colors) = match st.format { WebFormat::Png24 => { - let alpha = st.transparency && region().any(|p| p[3] < 1.0); - let k = if alpha { 4 } else { 3 }; - let mut data = Vec::with_capacity(w * h * k); - for p in region() { - let q = if alpha { *p } else { over(p) }; - data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); - } + let (data, layout) = true_color(); if preview { - look = if alpha { data.clone() } else { data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() }; + look = rgba(&data, layout); } - let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; - let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; - let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta); + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); let opts = photocraft_codecs::EncodeOptions { png_interlaced: st.interlaced, embed_icc: icc.is_some(), @@ -452,6 +475,27 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }; (photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None) } + WebFormat::Webp => { + let (data, layout) = true_color(); + let icc = icc.filter(|_| st.embed_icc); + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); + let opts = photocraft_codecs::EncodeOptions { + webp_lossless: st.lossless, + webp_quality: st.quality.min(100), + embed_icc: icc.is_some(), + embed_metadata: xmp.is_some(), + ..Default::default() + }; + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?; + if preview { + // Lossless shows the source; lossy shows the compression artefacts. + look = match (!st.lossless).then(|| photocraft_codecs::decode(&bytes).ok()).flatten() { + Some(img) => img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(), + None => rgba(img.data(), layout), + }; + } + (bytes, None) + } WebFormat::Jpeg => { let mut rgb = Vec::with_capacity(w * h * 3); for p in region() { @@ -1468,7 +1512,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|webp"="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 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..63f2ac7820 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -188,7 +188,7 @@ fn estimates_every_format_at_every_depth() { for depth in [8, 16, 32] { let mut s = session(depth); let mut sizes = Vec::new(); - for f in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for f in ["gif", "png8", "png24", "jpeg", "wbmp", "webp"] { let r = s.execute("file.export.saveForWebLegacy", json!({"format": f, "colors": 16})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(64), Some(48)), "{f}"); assert!(r["bytes"].as_u64().unwrap() > 20, "{f}"); @@ -286,6 +286,63 @@ fn jpeg_quality_progressive_and_metadata() { assert!(decode(&lo).meta.xmp.is_none()); } +/// WebP (#2415, #6391): lossy at the chosen quality or lossless, keeping translucency unless +/// transparency is off, with the chosen metadata and an optional colour profile. +#[test] +fn webp_lossy_lossless_transparency_and_metadata() { + let dir = tmp("webp"); + let mut s = session(16); + s.execute("file.fileInfo", json!({"copyright": "© Example", "description": "secret notes"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap(); + let path = |name: &str| format!("{dir}/{name}"); + let mut export = |name: &str, p: Value| { + let mut p = p; + p["format"] = json!("webp"); + p["path"] = json!(path(name)); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + std::fs::read(path(name)).unwrap() + }; + let lo = export("lo.webp", json!({"quality": 5})); + let hi = export("hi.webp", json!({"quality": 95, "embedIcc": true})); + let lossless = export("lossless.webp", json!({"lossless": true, "metadata": "none"})); + let opaque = export("opaque.webp", json!({"transparency": false, "matte": "#00ff00"})); + for bytes in [&lo, &hi, &lossless, &opaque] { + assert_eq!((&bytes[0..4], &bytes[8..12]), (&b"RIFF"[..], &b"WEBP"[..])); + } + assert!(lo.len() < hi.len(), "quality drives the size: {} vs {}", lo.len(), hi.len()); + let has = |b: &[u8], fourcc: &[u8]| b.windows(4).any(|w| w == fourcc); + assert!(has(&hi, b"VP8 ") && has(&hi, b"ALPH"), "lossy keeps translucency in an alpha chunk"); + assert!(has(&lossless, b"VP8L") && !has(&lossless, b"VP8 ")); + assert!(!has(&opaque, b"ALPH"), "transparency off composites over the matte"); + + // Lossless is the PNG-24 export's pixels exactly. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path("same.png")})).unwrap(); + let (a, b) = (decode(&path("lossless.webp")), decode(&path("same.png"))); + let rgba = |i: &photocraft_codecs::Image| i.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(); + assert_eq!(rgba(&a), rgba(&b)); + // The transparent corner stays transparent; with transparency off it is the matte. + assert_eq!(rgba(&a)[3], 0); + let o = rgba(&decode(&path("opaque.webp"))); + assert!(o[3] == 255 && o[1] > 200 && o[0] < 60, "{:?}", &o[..4]); + + let meta = decode(&path("hi.webp")); + let xmp = meta.meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example") && !xmp.contains("secret"), "copyright only"); + assert!(meta.icc.is_some(), "Embed Color Profile"); + assert!(decode(&path("lo.webp")).icc.is_none()); + assert!(a.meta.xmp.is_none(), "metadata none"); +} + +/// Sizes WebP cannot hold are an error, not a crash. +#[test] +fn webp_too_large_is_an_error() { + let mut s = session(8); + for lossless in [false, true] { + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "width": 20000, "height": 2})); + assert!(r.is_err(), "lossless {lossless}: {r:?}"); + } +} + #[test] fn slices_export_with_html_table() { let dir = tmp("slices"); @@ -621,6 +678,9 @@ fn web_settings_from_params() { assert!((st.web_snap - 0.5).abs() < 1e-6); assert!(WebSettings::from_params(&json!({"preset": "Nope"}), "x").is_err()); assert!(WebSettings::from_params(&json!({"palette": "weird"}), "x").is_err()); + let webp = WebSettings::from_params(&json!({"format": "webp", "lossless": true, "quality": 70}), "x").unwrap(); + assert_eq!((webp.format, webp.lossless, webp.quality), (WebFormat::Webp, true, 70)); + assert_eq!(WebSettings::from_params(&webp.to_params(), "x").unwrap(), webp); for p in PRESETS { let st = preset_settings(p).unwrap(); assert_eq!(WebSettings::from_params(&st.to_params(), "x").unwrap(), st, "{p} round-trips through params"); @@ -633,7 +693,7 @@ fn previews_show_the_optimised_pixels() { let doc = s.active().unwrap().doc.clone(); let (wd, _, _) = web_document(&doc, &json!({}), &WebSettings::default()).unwrap(); let buf = photocraft_compose::flatten(&wd); - for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { + for f in ["jpeg", "gif", "png8", "png24", "wbmp", "webp"] { 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(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..44a6ecb056 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -199,7 +199,7 @@ fn b(f: &Map, k: &str, d: bool) -> bool { #[path = "file_ui/web_preview.rs"] mod web_preview; -const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; +const WEB_FORMATS: [(&str, &str); 6] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("webp", "WebP"), ("wbmp", "WBMP")]; const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; pub fn open_web(app: &mut PhotocraftApp) -> u64 { @@ -228,6 +228,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { for (k, v) in [ ("format", json!("jpeg")), ("quality", json!(60)), + ("lossless", json!(false)), ("palette", json!("selective")), ("colors", json!(128)), ("dither", json!("diffusion")), @@ -258,7 +259,7 @@ fn variants(p: &Value) -> Vec { m.remove("colorTable"); m.remove("colorTableEdits"); } - if fmt == "jpeg" { + if fmt == "jpeg" || (fmt == "webp" && !p["lossless"].as_bool().unwrap_or(false)) { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { v["colors"] = json!((p["colors"].as_f64().unwrap_or(128.0) / k).round().max(2.0)); @@ -580,6 +581,14 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { + check(ui, f, "lossless", tl!("Lossless"), false); + if !b(f, "lossless", false) { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + } + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } "wbmp" => { dropdown_str( ui, @@ -595,7 +604,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-16 · **Change:** minor (Save for Web writes WebP, lossy or lossless, #6391; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -29,7 +29,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Save for Web (Legacy) offers WebP, lossy or lossless, with its own preview and size estimate (#2415, #6391) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 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 | From 24326695786cd0f63811618a51f9c97e182ec135 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 16:26:53 +0530 Subject: [PATCH 02/28] Free Transform moves every selected layer (#6501, #3384, #6096) With several layers selected, Ctrl/Cmd+T framed and transformed only the active layer, whatever its kind: a pixel layer moved while a type layer beside it stayed put (or the other way round). Engine: `edit.transform` takes `layers` and, given no `layer`, moves the selected layers that no selected group carries, leaving out the Background, as Photoshop does. One transform, one history step; every layer is priced and lock-checked before any is built. Transform Again repeats on the selection; Transform Again on a Copy keeps the active layer's frame. UI: the box frames all of them (`begin_several`) and its preview is their composite with styles reaching past the frame, rebuilt when an edit made while it is open changes how they look (children of a moving group and styles of enclosing groups included). Selected pixels are lifted per pixel layer. The Move tool's shared Show Transform Controls frame gets handles. Warp is greyed for several layers, and so are Distort and Perspective while editable type is among them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T48KpM3QwGvfQxtZ1icCRq --- crates/engine/src/edit_cmds.rs | 16 +- crates/engine/src/transform_cmds.rs | 119 +++++-- crates/engine/src/transform_multi_tests.rs | 209 ++++++++++++ crates/ui-egui/src/cam_transform.rs | 1 + crates/ui-egui/src/canvas.rs | 43 +-- crates/ui-egui/src/enable_rules.rs | 17 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/snap_ui.rs | 8 +- crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/transform_several_tests.rs | 304 +++++++++++++++++ crates/ui-egui/src/transform_tool.rs | 315 ++++++++++++++++-- crates/ui-egui/src/type_transform.rs | 1 + docs/gaps.md | 7 +- docs/ui-design.md | 2 +- docs/ui-parity.md | 6 +- 15 files changed, 967 insertions(+), 90 deletions(-) create mode 100644 crates/engine/src/transform_multi_tests.rs create mode 100644 crates/ui-egui/src/transform_several_tests.rs diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..74cbe8b812 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -705,9 +705,10 @@ fn toggle_last_state(s: &mut Session) -> Result { Ok(json!(if redo { s.redo() } else { s.undo() })) } -/// Edit › Transform › Again: replay the last `edit.transform` on the active layer. +/// Edit › Transform › Again: replay the last `edit.transform` on the selected layers (#6501). fn transform_again(s: &mut Session) -> Result { - let p = transform_again_params(s)?; + let ids = s.active().map(crate::transform_cmds::selection_targets).unwrap_or_default(); + let p = transform_again_params(s, &ids)?; s.execute("edit.transform", p) } @@ -715,8 +716,8 @@ fn transform_again(s: &mut Session) -> Result { /// last transform on the copy, as one history step, so pressing it again steps and repeats /// (#352). The transform is worked out before anything changes, so a refusal leaves no copy. fn transform_again_copy(s: &mut Session) -> Result { - let p = transform_again_params(s)?; let id = active_id(s)?; + let p = transform_again_params(s, &[id])?; let journal = s.journal.len(); // The steps are recorded (Again on a Copy is not replayable itself): with one layer selected, // "duplicate the current layer" plays back on any document, a layer id only on this one. @@ -742,8 +743,9 @@ fn transform_again_copy(s: &mut Session) -> Result { } } -/// The `edit.transform` params that repeat the last transform on the active layer. -fn transform_again_params(s: &Session) -> Result { +/// The `edit.transform` params that repeat the last transform on the layers `ids` (their frame +/// together is the box it starts from). +fn transform_again_params(s: &Session, ids: &[LayerId]) -> Result { let (_, last) = s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?; let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned()); @@ -762,8 +764,8 @@ fn transform_again_params(s: &Session) -> Result { ) .ok_or(EngineError::Other("the last transform can't be repeated".into()))?; let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; - let b = crate::transform_cmds::transform_bounds(&d.doc, l); + d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; + let b = crate::transform_cmds::transform_bounds_of(&d.doc, ids); if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..a81114cfd1 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -257,6 +257,51 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but +/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or +/// only the Background besides it), that layer; the active layer when nothing else is. +pub fn selection_targets(st: &crate::DocState) -> Vec { + let selected = st.selected_layers(); + if selected.len() < 2 { + return st.active_layer.into_iter().collect(); + } + let ids: Vec = crate::layer_multi_cmds::top_level(&st.doc, &selected).into_iter().filter(|id| !st.doc.is_background(*id)).collect(); + if ids.is_empty() { st.active_layer.into_iter().collect() } else { ids } +} + +/// The `layers` param: the layers one box transforms together (at most 10000, each in the +/// document), without those a listed group carries. `None` when it isn't given. +fn layers_param(doc: &Document, p: &Value) -> Result>> { + let a = match p.get("layers") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => a, + Some(v) => return Err(bad(format!("`layers` is a non-empty list of at most 10000 layer ids, got {v}"))), + }; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad(format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(Some(crate::layer_multi_cmds::top_level(doc, &ids))) +} + +/// The box Free Transform opens on the layers `ids` together: their frames ([`transform_frame`]) +/// joined, as Show Transform Controls draws it. Empty when none of them has anything to move. +pub fn transform_frame_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_frame(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + +/// [`transform_bounds`] of the layers `ids` together. +pub fn transform_bounds_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_bounds(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + /// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses /// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not /// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused @@ -573,6 +618,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { let mut p = if p.is_object() { p.clone() } else { json!({}) }; if p.get("target").is_none() && p.get("layer").is_none() + && p.get("layers").is_none() && let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s)) { m.extend(t); @@ -583,23 +629,38 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); - let lone = lone_target(&st.doc, id, p)?; + let many = layers_param(&st.doc, p)?; + if many.is_some() && (p.get("layer").is_some() || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) { + return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`")); + } + let explicit = crate::commands::explicit_layer(s, p)?; + let id = explicit.or(st.active_layer); + let lone = if many.is_some() { None } else { lone_target(&st.doc, id, p)? }; let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { (_, true) => None, (Some(id), false) => Some(id), + (None, false) if many.is_some() => None, (None, false) => return Err(EngineError::Other("no active layer".into())), }; + // The layers the box moves: those listed, the one named, or else the layer selection (#6501). + let ids: Vec = match (many, explicit) { + (Some(ids), _) => ids, + _ if lone.is_some() => Vec::new(), + (None, Some(id)) => vec![id], + (None, None) => selection_targets(st), + }; + if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { + return Err(EngineError::NoLayer(*missing)); + } let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { let v: Vec = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); [v[0], v[1], v[2], v[3]] } _ => { - let b = match (lone, id) { - (Some(surf), _) => target_bounds(&st.doc, surf), - (None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), - (None, None) => Rect::EMPTY, + let b = match lone { + Some(surf) => target_bounds(&st.doc, surf), + None => transform_bounds_of(&st.doc, &ids), }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); @@ -629,22 +690,25 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); + let layers: Vec = ids.iter().map(|id| id.0).collect(); let p = p.clone(); // A background job when started with `Session::start` (#1015): the warp of a large layer runs // on a worker with progress, and can be cancelled. crate::jobs::edit_job( s, "Free Transform", - move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), - move |()| json!({"rect": rect}), + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)), + move |()| json!({"rect": rect, "layers": layers}), ) } -/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +/// The edit of [`transform`]: the layers `ids` (or the lone mask or channel of `id`) and the +/// selection outline. #[allow(clippy::too_many_arguments)] fn transform_doc( doc: &mut Document, id: Option, + ids: &[LayerId], p: &Value, lone: bool, h: &Homography, @@ -673,18 +737,31 @@ fn transform_doc( } return Ok(()); } - let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = group_locks(doc, id); - // Photoshop turns the Background into a normal layer before transforming it. - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?; + if ids.is_empty() { + return Err(EngineError::Other("no active layer".into())); + } + // A group moves whole: the selection doesn't limit what is inside it. + let layer_sel = |l: &Layer| if l.is_group() { None } else { sel.as_ref() }; + // Every layer is priced before any is built, so one that doesn't fit leaves all as they were. + for &id in ids { + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check_layer_budget(layer_sel(l), l, h)?; + } + for &id in ids { + let group = group_locks(doc, id); + // Photoshop turns the Background into a normal layer before transforming it. + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let doc_sel = layer_sel(l); + apply_transform(doc_sel, group, l, h, affine, interp, ctl)?; + } if !h.is_identity() { // Type layers re-render from their new transform. let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - refresh_text(&snapshot, l); + for &id in ids { + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } } // The selection outline moves with the pixels. if let Some(sel) = &doc.selection { @@ -700,7 +777,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, @@ -1424,3 +1501,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "transform_multi_tests.rs"] +mod multi_tests; diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs new file mode 100644 index 0000000000..d8f508f50b --- /dev/null +++ b/crates/engine/src/transform_multi_tests.rs @@ -0,0 +1,209 @@ +//! Free Transform of several selected layers by one box (#6501). + +use super::*; + +/// A 200 x 200 document: a red 40 x 20 block on "Paint" (x 10..50, y 10..30) and a type layer +/// over it, both selected, the type layer active (as after creating it and ⌘-clicking Paint). +fn two_layers() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let paint = LayerId(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(paint).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let text = LayerId(s.execute("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + (s, paint, text) +} + +fn bounds(s: &Session, id: LayerId) -> Rect { + let st = s.active().unwrap(); + transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn text_matrix(s: &Session, id: LayerId) -> [f64; 6] { + let st = s.active().unwrap(); + let LayerContent::Text(t) = &st.doc.layer(id).unwrap().content else { panic!("not a type layer") }; + t.transform.m +} + +#[test] +fn free_transform_moves_every_selected_layer_in_one_step() { + let (mut s, paint, text) = two_layers(); + let (p0, t0, m0) = (bounds(&s, paint), bounds(&s, text), text_matrix(&s, text)); + let steps = s.active().unwrap().history.past_len(); + // Before #6501 only the active layer moved, whichever kind it was. + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + assert_eq!(bounds(&s, paint), p0.translate(30, 40)); + assert_eq!(bounds(&s, text), t0.translate(30, 40)); + let st = s.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + let history = s.execute("document.inspect", json!({})).unwrap()["history"].clone(); + assert_eq!(history.as_array().unwrap().last(), Some(&json!("Free Transform"))); + // The type stays editable: it keeps the transform instead of pixels. + assert_eq!(text_matrix(&s, text)[4..], [m0[4] + 30.0, m0[5] + 40.0]); + s.undo(); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p0, t0), "and one Undo takes both back"); +} + +#[test] +fn the_default_frame_is_the_selected_layers_together() { + let (mut s, paint, text) = two_layers(); + let both = bounds(&s, paint).union(&bounds(&s, text)); + let m0 = text_matrix(&s, text); + // 200 % about the joined frame's centre: each layer scales about that point, not its own. + let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from)); + let (cx, cy) = (f64::from(both.x0 + both.x1) / 2.0, f64::from(both.y0 + both.y1) / 2.0); + let p = bounds(&s, paint); + assert_eq!((f64::from(p.x0), f64::from(p.y0)), (2.0 * 10.0 - cx, 2.0 * 10.0 - cy)); + let m = text_matrix(&s, text); + assert!((m[0] - 2.0).abs() < 1e-9 && (m[3] - 2.0).abs() < 1e-9, "{m:?}"); + assert!((m[4] - (2.0 * m0[4] - cx)).abs() < 1e-9 && (m[5] - (2.0 * m0[5] - cy)).abs() < 1e-9, "{m:?}"); +} + +#[test] +fn an_explicit_layer_still_transforms_that_layer_alone() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layer": paint.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + +#[test] +fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + // The group and the layer in it: the layer moves once, with its group. + let r = s.execute("edit.transform", json!({"layers": [paint.0, group.0], "matrix": [1, 0, 0, 1, 7, 0]})).unwrap(); + assert_eq!(r["layers"], json!([group.0])); + assert_eq!(bounds(&s, paint), Rect::new(17, 10, 57, 30)); + assert_eq!(bounds(&s, text), t0, "not listed, not moved"); +} + +#[test] +fn the_background_stays_out_of_a_selection_of_several() { + let (mut s, paint, text) = two_layers(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + let st = s.active().unwrap(); + assert!(st.doc.is_background(bg), "still the Background"); + assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); +} + +#[test] +fn a_selection_limits_the_pixels_of_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + // The left half of the block: only those pixels of Paint move; the type moves whole (#2630). + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 100]})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(paint).unwrap().surface().unwrap(); + assert_eq!(surf.content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(surf.sample_channel(20, 15, 3), 0.0, "the selected part left"); + assert_eq!(surf.sample_channel(40, 15, 3), 1.0, "the rest stayed"); + assert_eq!(surf.sample_channel(20, 115, 3), 1.0, "and landed below"); + assert_eq!(bounds(&s, text), t0.translate(0, 100)); +} + +#[test] +fn a_locked_layer_among_them_refuses_the_whole_transform() { + let (mut s, paint, text) = two_layers(); + s.execute("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even the unlocked layer"); +} + +#[test] +fn one_layer_past_the_pixel_budget_leaves_every_layer_as_it_was() { + let (mut s, paint, _) = two_layers(); + // A 1 px dot, beside the 40 px block: only the block, stretched, outgrows the test budget. + let dot = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("dot", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 40, 11, 41), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 4e4; + assert!(s.execute("edit.transform", json!({"layers": [dot.0, paint.0], "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision); +} + +#[test] +fn distort_with_type_among_the_layers_is_refused() { + let (mut s, _, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let quad = json!({"rect": [0, 0, 100, 100], "quad": [[0, 0], [100, 0], [120, 100], [-20, 100]]}); + assert!(s.execute("edit.transform", quad).is_err()); + assert!(s.active().unwrap().doc == before); +} + +#[test] +fn transform_again_repeats_on_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let (p0, t0) = (bounds(&s, paint), bounds(&s, text)); + let both = p0.union(&t0); + let r = [both.x0, both.y0, both.x1, both.y1].map(f64::from); + let quad = [[r[0] + 10.0, r[1]], [r[2] + 10.0, r[1]], [r[2] + 10.0, r[3]], [r[0] + 10.0, r[3]]]; + // Nearest neighbour: no resampling fringe to blur the bounds. + s.execute("edit.transform", json!({"rect": r, "quad": quad, "interpolation": "nearest"})).unwrap(); + s.execute("edit.transform.again", json!({})).unwrap(); + assert_eq!(bounds(&s, paint), p0.translate(20, 0)); + assert_eq!(bounds(&s, text), t0.translate(20, 0)); +} + +#[test] +fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() { + let (mut s, paint, text) = two_layers(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap(); + let (p1, t1) = (bounds(&s, paint), bounds(&s, text)); + s.execute("edit.transform.againCopy", json!({})).unwrap(); + let st = s.active().unwrap(); + let copy = st.active_layer.unwrap(); + assert!(copy != paint && copy != text); + assert_eq!(bounds(&s, copy), t1.translate(10, 0), "the copy of the active type layer moved on"); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed"); +} + +#[test] +fn bad_layers_params_are_errors_not_panics() { + let (mut s, paint, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let m = json!([1, 0, 0, 1, 5, 0]); + for p in [ + json!({"layers": [], "matrix": m}), + json!({"layers": "all", "matrix": m}), + json!({"layers": [paint.0, "x"], "matrix": m}), + json!({"layers": [-1], "matrix": m}), + json!({"layers": [987_654], "matrix": m}), + json!({"layers": [u64::MAX], "matrix": m}), + json!({"layers": (0..10_001).collect::>(), "matrix": m}), + json!({"layers": [paint.0], "layer": paint.0, "matrix": m}), + json!({"layers": [paint.0], "target": "quickMask", "matrix": m}), + json!({"layers": [paint.0], "target": {"channel": 0}, "matrix": m}), + ] { + assert!(s.execute("edit.transform", p.clone()).is_err(), "{p}"); + } + assert!(s.active().unwrap().doc == before); +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 995645940e..3677a68835 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -60,6 +60,7 @@ pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }; app.ui.cam_transform = Some(CamTransform { document, revision, offset, frame, gesture: None }); app.ui.status = crate::i18n::fmt(tl!("Scale or rotate the moved area, then press {key}"), &[("key", &crate::shortcuts::pretty("Enter"))]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..06c9e7a197 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3879,10 +3879,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(&ctx, "scan", p); - } else if let Some((r, p, c)) = response - .hover_pos() - .filter(|_| !transform_controls_several(app)) - .and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = + response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -4731,12 +4729,6 @@ fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() } -/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but -/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. -fn transform_controls_several(app: &PhotocraftApp) -> bool { - transform_controls_layers(app).len() > 1 -} - /// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected /// layers, joined into one. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { @@ -4800,11 +4792,9 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { transform_controls_cursor(r, p).is_some() } -/// Enter the existing Free Transform session when a Move-tool transform control is pressed. +/// Enter the existing Free Transform session when a Move-tool transform control is pressed: on +/// every selected layer, as the frame shows them (#6501). fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { - if transform_controls_several(app) { - return false; - } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -4818,8 +4808,8 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles -/// for one layer, and the outline of every selected shape layer, as in Photoshop. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles, +/// and the outline of every selected shape layer, as in Photoshop. fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; @@ -4830,9 +4820,6 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: } } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); - if transform_controls_several(app) { - return; - } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); @@ -8196,34 +8183,36 @@ mod transform_controls_tests { use serde_json::json; #[test] - fn several_selected_layers_share_one_frame_without_handles() { - // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform - // works on one layer, so the shared frame doesn't start it. + fn several_selected_layers_share_one_frame_that_transforms_them_all() { + // As in Photoshop: Show Transform Controls frames every selected layer, and its handles + // start one Free Transform of all of them (#6501). let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); let a = app.session.active().unwrap().active_layer.unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); app.sync_views(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; let one = transform_controls_rect(&app, &xf).unwrap(); - assert!(!transform_controls_several(&app)); app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); - assert!(transform_controls_several(&app)); let both = transform_controls_rect(&app, &xf).unwrap(); assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); - let ctx = egui::Context::default(); - assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); - assert!(app.ui.transform.is_none()); // The Background never joins the frame. let bg = app.session.active().unwrap().doc.layers[0].id; app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); assert_eq!(transform_controls_layers(&app).len(), 2); + assert_eq!(transform_controls_rect(&app, &xf), Some(both)); + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + let t = app.ui.transform.as_ref().expect("the shared frame's handle starts Free Transform"); + assert_eq!(t.rect, [20.0, 30.0, 150.0, 150.0]); + assert_eq!(t.layers, [a.0, b.0]); } #[test] diff --git a/crates/ui-egui/src/enable_rules.rs b/crates/ui-egui/src/enable_rules.rs index e3712c1a9a..6abf64fd1d 100644 --- a/crates/ui-egui/src/enable_rules.rs +++ b/crates/ui-egui/src/enable_rules.rs @@ -126,6 +126,23 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool { if transform && (target.is_object() || target == "quickMask") { return false; } + // Several layers selected: one box moves them all, the Background left out (#6501). It doesn't + // warp, and doesn't distort while editable type is among them. + if transform { + let targets = photocraft_engine::transform_cmds::selection_targets(st); + if targets.len() > 1 || targets.first() != st.active_layer.as_ref() { + let text = targets.iter().any(|t| st.doc.layer(*t).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))); + return match id { + "edit.transform.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" => true, + "edit.transform.distort" | "edit.transform.perspective" => text, + _ => false, + }; + } + } // Editable type can be scaled, rotated and skewed but not distorted (#2630): Photoshop greys // Distort and Perspective until the type is rasterized, and the split warps (Warp on type // offers only the warp styles, #3004). A targeted unlinked mask or channel of the type layer diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..3cb3121eee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2689,6 +2689,9 @@ mod transform_undo_tests; #[cfg(test)] mod transform_type_tests; +#[cfg(test)] +mod transform_several_tests; + #[cfg(test)] mod save_identity_tests; diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..d562c1fc0b 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -195,15 +195,17 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { - crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + crate::transform_tool::Hit::Corner(index) => { + t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, crate::transform_tool::moving_layers(t))) + } _ => None, }; if t.mode == crate::state::TransformMode::Distort { corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) + corner.or_else(|| Some((Gesture::Point, crate::transform_tool::moving_layers(t)))) } else if in_quad(&t.quad, p) { - Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) + Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, crate::transform_tool::moving_layers(t))) } else { None } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..559322f192 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -936,6 +936,12 @@ pub struct TransformSession { /// skew and rotation with any vertical skew, so the bar keeps showing the typed one. #[serde(default)] pub skew_v: f64, + /// Free Transform of several selected layers (#6501): every layer the box moves, in + /// `edit.transform`'s `layers` order. Empty when it moves `layer` alone. `layer` stays the + /// active layer, which need not be one of them (the Background is left out, a selected group + /// carries its selected children). + #[serde(default)] + pub layers: Vec, } /// Content-Aware Scale's options bar: Amount, Protect and Protect Skin Tones. diff --git a/crates/ui-egui/src/transform_several_tests.rs b/crates/ui-egui/src/transform_several_tests.rs new file mode 100644 index 0000000000..30330a5b6f --- /dev/null +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -0,0 +1,304 @@ +//! Free Transform with several layers selected (#6501): ⌘T frames them all and the box moves +//! every one of them, type and pixels alike, in one history step. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; + +/// A 200 × 200 document: "Paint" with a red block at x 10..50, y 10..30, and a type layer over it. +/// Both are selected with Paint active, as after ⌘-clicking Paint in the Layers panel (the +/// reporter's steps). +fn app() -> (PhotocraftApp, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + let paint = LayerId(app.run("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let text = LayerId(app.run("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(paint)); + (app, paint, text) +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + let st = app.session.active().unwrap(); + photocraft_engine::transform_cmds::transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn free_transform(app: &mut PhotocraftApp) { + crate::menus::invoke(app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); +} + +#[test] +fn free_transform_frames_every_selected_layer_and_moves_them_all() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + let both = p0.union(&t0); + assert_eq!(t.rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from), "the box frames both layers"); + assert_eq!((t.layer, t.layers.clone()), (paint.0, vec![paint.0, text.0])); + // Both move with the box: neither stays behind on the canvas under it. + let pv = app.transform_preview.as_ref().unwrap(); + assert!(!pv.doc.layer(paint).unwrap().visible && !pv.doc.layer(text).unwrap().visible); + let steps = app.session.active().unwrap().history.past_len(); + // A quarter turn about the box's centre. + let r = t.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + assert!(app.ui.transform.is_none()); + let (p1, t1) = (bounds(&app, paint), bounds(&app, text)); + assert!(p1.width().abs_diff(p0.height()) <= 1 && p1.height().abs_diff(p0.width()) <= 1, "Paint turned: {p0:?} -> {p1:?}"); + assert!(t1.width().abs_diff(t0.height()) <= 2 && t1.height().abs_diff(t0.width()) <= 2, "the type turned: {t0:?} -> {t1:?}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(text).unwrap().content, LayerContent::Text(_)), "still editable type"); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + assert_eq!(st.selected_layers(), [paint, text], "both stay selected"); +} + +#[test] +fn with_a_selection_the_pixels_are_lifted_and_the_type_moves_whole() { + let (mut app, paint, text) = app(); + let t0 = bounds(&app, text); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + free_transform(&mut app); + let pv = app.transform_preview.as_ref().unwrap(); + let under = pv.doc.layer(paint).unwrap(); + assert!(under.visible, "Paint stays, less the selected pixels"); + assert_eq!(under.surface().unwrap().content_bounds(), Rect::new(30, 10, 50, 30)); + assert!(!pv.doc.layer(text).unwrap().visible, "the type moves whole (#2630)"); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x, y + 100.0]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(paint).unwrap().surface().unwrap().content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(bounds(&app, text), t0.translate(0, 100)); +} + +#[test] +fn cancel_leaves_every_layer_as_it_was() { + let (mut app, paint, text) = app(); + let before = app.session.active().unwrap().doc.clone(); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y]); + } + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none()); + let st = app.session.active().unwrap(); + assert!(st.doc == before); + assert_eq!(st.selected_layers(), [paint, text]); +} + +#[test] +fn warp_is_greyed_and_distort_too_while_type_is_among_them() { + let (mut app, paint, text) = app(); + for id in ["edit.freeTransform", "edit.transform.scale", "edit.transform.rotate", "edit.transform.skew", "edit.transform.flipHorizontal"] { + assert!(crate::menus::is_enabled(&app, id), "{id}"); + } + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed"); + } + assert_eq!(crate::transform_tool::begin_warp(&mut app, &egui::Context::default()), Err(crate::transform_tool::WARP_ONE_LAYER.into())); + assert!(app.ui.transform.is_none(), "no box is left open"); + // In an open box too. + free_transform(&mut app); + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed in the box"); + } + crate::transform_tool::cancel(&mut app); + // Two pixel layers distort. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.rasterize.type", json!({})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + assert!(crate::menus::is_enabled(&app, "edit.transform.distort") && crate::menus::is_enabled(&app, "edit.transform.perspective")); + assert!(!crate::menus::is_enabled(&app, "edit.transform.warp")); +} + +#[test] +fn a_locked_layer_among_them_refuses_before_the_box_opens() { + let (mut app, paint, text) = app(); + app.run("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})); + assert_eq!(r, Err(crate::transform_tool::LOCKED.into())); + assert!(app.ui.transform.is_none()); +} + +#[test] +fn with_the_background_active_the_box_moves_the_other_selected_layers() { + let (mut app, paint, text) = app(); + let bg = app.session.active().unwrap().doc.layers[0].id; + app.run("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(bg)); + assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "not greyed for the Background"); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!((t.layer, t.layers.clone()), (bg.0, vec![paint.0, text.0])); + let p0 = bounds(&app, paint); + if let Some(t) = app.ui.transform.as_mut() { + // Nearest neighbour: no resampling fringe at the block's edge. + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 5.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert!(st.doc.is_background(bg), "the Background is left alone"); + assert_eq!(bounds(&app, paint), p0.translate(5, 0)); +} + +#[test] +fn picking_another_layer_applies_the_box_to_all_of_them() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_none()); + assert_eq!(bounds(&app, paint), p0.translate(10, 0)); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +/// Paint with a 6 px outside stroke, then both layers selected again (Paint active). +fn stroked() -> (PhotocraftApp, LayerId, LayerId) { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.layerStyle.stroke", json!({"size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + (app, paint, text) +} + +#[test] +fn styles_past_the_frame_move_with_the_box() { + let (mut app, _, _) = stroked(); + free_transform(&mut app); + // The frame is the content's (the block starts at x 10); its stroke reaches 6 px past it. + assert_eq!(app.ui.transform.as_ref().unwrap().rect[0], 10.0, "the handles stay on the content"); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the stroke left of the block is in the moving pixels: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "and drawn there: {quad:?}"); + // Moving the box carries the texture's own rect with it. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 50.0, y + 5.0]); + } + let (_, moved) = crate::transform_tool::moving_texel(&app, [6, 20]); + for (a, b) in quad.iter().zip(&moved) { + assert!((b[0] - a[0] - 50.0).abs() < 1e-9 && (b[1] - a[1] - 5.0).abs() < 1e-9, "{quad:?} -> {moved:?}"); + } + // A quarter turn maps the stroke's corner where the frame's map takes it. + let r = app.ui.transform.as_ref().unwrap().rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = crate::transform_tool::corners(r).map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + let (_, turned) = crate::transform_tool::moving_texel(&app, [6, 20]); + let [x0, y0] = quad[0]; + assert!((turned[0][0] - (cx - (y0 - cy))).abs() < 1e-6 && (turned[0][1] - (cy + (x0 - cx))).abs() < 1e-6, "{turned:?}"); +} + +/// A texel of the type's glyphs in the moving pixels as the box opened. +fn glyph_texel(app: &PhotocraftApp, text: LayerId) -> [i32; 2] { + let b = bounds(app, text); + (b.y0..b.y1) + .flat_map(|y| (b.x0..b.x1).map(move |x| [x, y])) + .find(|p| crate::transform_tool::moving_texel(app, *p).0.is_some_and(|c| c.a() > 200)) + .expect("the type is in the moving pixels") +} + +#[test] +fn hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once() { + let (mut app, _, text) = app(); + free_transform(&mut app); + let p = glyph_texel(&app, text); + // Paint stays the active layer, so the box stays open. + app.run("layer.setProps", json!({"layer": text.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some()); + let half = crate::transform_tool::moving_texel(&app, p).0.unwrap(); + assert!((100..=155).contains(&half.a()), "half opacity: {half:?}"); + app.run("layer.setProps", json!({"layer": text.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(crate::transform_tool::moving_texel(&app, p).0.map(|c| c.a()), Some(0), "a hidden layer moves nothing"); + // The commit still transforms it, hidden, like Photoshop. + let t0 = bounds(&app, text); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +#[test] +fn a_style_added_while_the_box_is_open_grows_the_moving_pixels() { + let (mut app, paint, _) = app(); + free_transform(&mut app); + assert!(crate::transform_tool::moving_texel(&app, [6, 20]).0.is_none(), "nothing past the block yet"); + app.run("layer.layerStyle.stroke", json!({"layer": paint.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + let stroke = crate::transform_tool::moving_texel(&app, [6, 20]).0; + assert!(stroke.is_some_and(|c| c.a() > 200), "{stroke:?}"); +} + +fn group(app: &mut PhotocraftApp, ids: &[LayerId]) -> LayerId { + app.run("layer.select", json!({"layer": ids[0].0})).unwrap(); + for id in &ids[1..] { + app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + } + LayerId(app.run("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()) +} + +#[test] +fn edits_to_the_children_of_a_moving_group_show_in_the_preview() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint]); + app.run("layer.select", json!({"layer": g.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [g.0, text.0]); + let red = |app: &PhotocraftApp| crate::transform_tool::moving_texel(app, [20, 20]).0.map(|c| c.a()); + assert_eq!(red(&app), Some(255), "the group's child is in the moving pixels"); + app.run("layer.setProps", json!({"layer": paint.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert!(red(&app).is_some_and(|a| (100..=155).contains(&a)), "the child at half opacity: {:?}", red(&app)); + app.run("layer.setProps", json!({"layer": paint.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(red(&app), Some(0), "a hidden child moves nothing"); +} + +#[test] +fn a_style_of_the_group_around_the_moving_layers_moves_with_them() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint, text]); + app.run("layer.layerStyle.stroke", json!({"layer": g.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + // The two layers in the group, not the group itself. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [paint.0, text.0]); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the group's stroke left of the block: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "{quad:?}"); +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d46ef189bc..4b1c42254d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -91,15 +91,87 @@ pub struct TransformPreview { committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, + /// A box over several layers (#6501): their composite, which `texture` holds. + several: Option, +} + +/// The moving pixels of a box over several layers ([`begin_several`]): the layers composited on +/// their own, with their styles, opacity and masks. Composited again when an edit made while the +/// box is open changes how they look ([`follow_edits`]), since nothing is tinted at draw time. +struct Several { + ctx: egui::Context, + name: String, + max_side: usize, + ids: Vec, + /// The pixels a selection lifted out of moving pixel layers, as the box opened. Every other + /// moving layer (groups, type, shapes, smart objects, pixel layers without a selection) moves + /// whole, so the live document holds what it moves, children and their looks included. + lifted: Vec<(LayerId, photocraft_raster::Surface)>, + /// The document showing only the moving layers, as last composited. + moving: Document, + /// The box's frame. + frame: photocraft_geom::Rect, + /// The document area the texture covers: the frame grown by the reach of the moving layers' + /// styles, so a shadow, glow or outside stroke past their content moves with the box. + rect: photocraft_geom::Rect, +} + +impl Several { + /// `live` showing only the layers `ids`, with the `lifted` pixels in place of their layers'. + /// Unclipped: a clipping base that isn't moving would clip them away (#5239). + fn shown(live: &Document, ids: &[LayerId], lifted: &[(LayerId, photocraft_raster::Surface)]) -> Document { + let mut d = live.clone(); + show_only(&mut d.layers, ids); + for id in ids { + if let Some(l) = d.layer_mut(*id) { + l.clipped = false; + } + } + for (id, surf) in lifted { + if let Some(s) = d.layer_mut(*id).and_then(|l| l.surface_mut()) { + *s = surf.clone(); + } + } + d + } + + /// Composite the moving layers over their frame and their styles' reach. + fn render(&mut self) -> crate::transform_tex::PreviewTextures { + self.rect = self.frame.inflate(style_reach(&self.moving, &self.ids)); + let (image, uv) = composite_image(&self.moving, self.rect, self.max_side); + crate::transform_tex::PreviewTextures::new(&self.ctx, self.name.clone(), image, uv) + } +} + +/// How far past their content the styles of the layers `ids` can draw, in document pixels: their +/// own, the layers' inside them and the groups' around them, since a group's style draws around +/// its children's composite and so adds to theirs. +fn style_reach(doc: &Document, ids: &[LayerId]) -> i32 { + let reach_of = + |l: &photocraft_doc::Layer| if l.visible && photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l).max(0) } else { 0 }; + // The groups around each layer: everything on its path but itself. + let around = + |id: LayerId| doc.path_of(id).map_or(0, |p| (1..p.len()).filter_map(|n| doc.layer_at(p.get(..n)?)).fold(0i32, |r, g| r.saturating_add(reach_of(g)))); + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack: Vec<(&photocraft_doc::Layer, i32)> = ids.iter().filter_map(|id| Some((doc.layer(*id)?, around(*id)))).collect(); + let mut reach = 0; + while let Some((l, outer)) = stack.pop() { + let here = outer.saturating_add(reach_of(l)); + reach = reach.max(here); + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().map(|c| (c, here))); + } + } + reach } /// What [`follow_edits`] needs to rebuild the preview document. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct Follow { /// The document revision `doc` was built from. revision: u64, - /// The moving layer is hidden in `doc` (nothing was lifted out of it). - hidden: bool, + /// The moving layers hidden in `doc` (nothing was lifted out of them). + hidden: Vec, } /// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls) @@ -183,7 +255,7 @@ impl Steps { } } -fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { +pub(crate) fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } @@ -204,6 +276,11 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> return begin_lone(app, ctx, doc, surf, target); } let id = st.active_layer.ok_or("no active layer")?; + // Several layers selected: one box moves them all, as in Photoshop (#6501). + let targets = photocraft_engine::transform_cmds::selection_targets(st); + if targets != [id] { + return begin_several(app, ctx, doc, revision, id, targets); + } let layer = doc.layer(id).ok_or("no layer")?; if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { // Photoshop refuses before the box opens rather than when it is committed (#2585). @@ -251,7 +328,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> opacity: layer.opacity * layer.fill_opacity, above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), - follow: Some(Follow { revision, hidden: lifted.is_none() }), + follow: Some(Follow { revision, hidden: if lifted.is_none() { vec![id] } else { Vec::new() } }), committing: None, gesture: None, warp_drag: None, @@ -263,6 +340,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -279,11 +357,135 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) } +/// Free Transform of the selected layers `ids` together (#6501): the box frames them all, and the +/// preview moves their composite, drawn over the layers they cover. `active` stays the session's +/// `layer`, so picking another layer still applies the box. The selected pixels of each pixel +/// layer move when there is a selection; type, shapes, smart objects and groups move whole. +fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc, revision: u64, active: LayerId, ids: Vec) -> Result<(), String> { + use photocraft_engine::transform_cmds as tc; + if ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); + return Err(LOCKED.into()); + } + let b = tc::transform_frame_of(&doc, &ids); + let top = *ids.last().ok_or("no layer")?; + if b.is_empty() { + return Err("Could not transform: the layers are empty".into()); + } + crate::type_tool::commit(app); + let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]; + let session = app.ui.alloc_id(); + // `under`: the canvas under the box, without the moving pixels. `moving`: those pixels alone. + let mut under = (*doc).clone(); + let mut lifted = Vec::new(); + let mut hidden = Vec::new(); + for &id in &ids { + let Some(l) = doc.layer(id) else { continue }; + match (doc.selection.as_ref().filter(|_| !tc::moves_whole(l)), l.surface()) { + (Some(sel), Some(surf)) => { + let (part, rest) = tc::split_selected(surf, sel).map_err(|e| e.to_string())?; + if let Some(s) = under.layer_mut(id).and_then(|l| l.surface_mut()) { + *s = rest; + } + lifted.push((id, part)); + } + _ => { + if let Some(l) = under.layer_mut(id) { + l.visible = false; + } + hidden.push(id); + } + } + } + let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); + let above = Above::new(ctx, format!("transform-{session}-above"), &doc, top, max_side); + above.hide_in(&mut under.layers); + // Opacity, fill, masks and styles are in the composite already. + let mut several = Several { + ctx: ctx.clone(), + name: format!("transform-{session}"), + max_side, + moving: Several::shown(&doc, &ids, &lifted), + lifted, + ids: ids.clone(), + frame: b, + rect: b, + }; + let texture = several.render(); + app.transform_preview = Some(TransformPreview { + session, + doc: Arc::new(under), + texture, + opacity: 1.0, + above: Some(above), + styled: None, + follow: Some(Follow { revision, hidden }), + committing: None, + gesture: None, + warp_drag: None, + split_tool: None, + split_pointer: None, + split_placing: false, + split_quick: false, + steps: Steps::default(), + tool: app.ui.tool, + path: None, + content_aware: None, + several: Some(several), + }); + app.ui.transform = Some(TransformSession { + session, + layer: active.0, + rect, + quad: corners(rect), + pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + layers: ids.iter().map(|id| id.0).collect(), + }); + start_steps(app); + Ok(()) +} + +/// The moving texture's texel at document point `p` as the box opened (full resolution), and +/// where the texture's corners are drawn now: what a test of a box over several layers sees. +#[cfg(test)] +pub(crate) fn moving_texel(app: &PhotocraftApp, p: [i32; 2]) -> (Option, [[f64; 2]; 4]) { + let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return (None, [[0.0; 2]; 4]) }; + let r = pv.several.as_ref().map_or(photocraft_geom::Rect::new(t.rect[0] as i32, t.rect[1] as i32, t.rect[2] as i32, t.rect[3] as i32), |sv| sv.rect); + let texel = pv.texture.level_image(0).and_then(|(image, _)| { + let (x, y) = (usize::try_from(p[0] - r.x0).ok()?, usize::try_from(p[1] - r.y0).ok()?); + (x < image.size[0] && y < image.size[1]).then(|| image.pixels[y * image.size[0] + x]) + }); + (texel, texture_quad(t, pv)) +} + +/// The layers the box moves: every layer of a several-layer session (#6501), else its one layer. +pub(crate) fn moving_layers(t: &TransformSession) -> Vec { + if t.layers.is_empty() { vec![LayerId(t.layer)] } else { t.layers.iter().copied().map(LayerId).collect() } +} + +/// Is any of the layers the box moves of this kind? +fn moves_kind(app: &PhotocraftApp, t: &TransformSession, kind: fn(&LayerContent) -> bool) -> bool { + app.session.active().is_some_and(|st| moving_layers(t).iter().any(|id| st.doc.layer(*id).is_some_and(|l| kind(&l.content)))) +} + +/// Why Warp refuses with several layers selected. +pub const WARP_ONE_LAYER: &str = "Warp works on one layer at a time: select a single layer"; + /// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection, /// its selected pixels, as Layer via Copy) and transforms the copy (#352). pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { @@ -351,7 +553,7 @@ pub(crate) fn begin_content_aware( texture, opacity: layer.opacity * layer.fill_opacity, above: Some(above), - follow: Some(Follow { revision, hidden: false }), + follow: Some(Follow { revision, hidden: Vec::new() }), committing: None, gesture: None, warp_drag: None, @@ -364,6 +566,7 @@ pub(crate) fn begin_content_aware( path: None, content_aware: Some(live), styled: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -380,6 +583,7 @@ pub(crate) fn begin_content_aware( mode: Default::default(), content_aware: Some(Default::default()), skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -485,6 +689,7 @@ fn begin_lone( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -501,6 +706,7 @@ fn begin_lone( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -538,6 +744,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: above: None, follow: None, committing: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -554,6 +761,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -623,6 +831,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -639,6 +848,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -649,9 +859,16 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) { return Err(CAS_ONLY_SCALES.into()); } + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return Err(WARP_ONE_LAYER.into()); + } let fresh = app.ui.transform.is_none(); if fresh { begin(app, ctx)?; + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + cancel(app); + return Err(WARP_ONE_LAYER.into()); + } } enter_warp(app, ctx); // Started in Warp: that is where Undo stops. Switching an open transform to Warp is a step. @@ -671,8 +888,9 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { let existing = app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); let Some(t) = app.ui.transform.as_mut() else { return }; - // Warp moves layers and paths (#3027): a lone mask or channel keeps the box. - if t.warp.is_some() || t.target.is_some() { + // Warp moves layers and paths (#3027): a lone mask or channel keeps the box, and so do + // several layers (#6501). + if t.warp.is_some() || t.target.is_some() || !t.layers.is_empty() { return; } if t.quad == corners(t.rect) @@ -1055,7 +1273,12 @@ pub fn commit(app: &mut PhotocraftApp) { if t.quad == corners(t.rect) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } - let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + let mut p = json!({"rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + if t.layers.is_empty() { + p["layer"] = json!(t.layer); + } else { + p["layers"] = json!(t.layers); + } if let Some(target) = &t.target { p["target"] = target.clone(); } @@ -1144,7 +1367,7 @@ pub fn end_if_left(app: &mut PhotocraftApp) { if editing_placed_smart_object { app.ui.transform = None; app.transform_preview = None; - } else if switched_document || st.doc.layer(layer).is_none() { + } else if switched_document || moving_layers(t).iter().any(|id| st.doc.layer(*id).is_none()) || st.doc.layer(layer).is_none() { cancel(app); } else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) @@ -1166,18 +1389,33 @@ pub fn follow_edits(app: &mut PhotocraftApp) { let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return }; let Some(follow) = pv.follow.as_mut() else { return }; let Some(st) = app.session.active().filter(|st| st.doc.id == pv.doc.id && st.revision != follow.revision) else { return }; - let id = LayerId(t.layer); - let (Some(live), Some(moving)) = (st.doc.layer(id), pv.doc.layer(id)) else { return }; + let ids = moving_layers(t); let mut pd = (*st.doc).clone(); - if let Some(l) = pd.layer_mut(id) { - l.content = moving.content.clone(); - l.visible = l.visible && !follow.hidden; + for id in &ids { + let (Some(_), Some(moving)) = (st.doc.layer(*id), pv.doc.layer(*id)) else { return }; + if let Some(l) = pd.layer_mut(*id) { + l.content = moving.content.clone(); + l.visible = l.visible && !follow.hidden.contains(id); + } } - if let Some(a) = pv.above.as_mut() { - a.rebuild(&st.doc, id); + if let (Some(a), Some(top)) = (pv.above.as_mut(), ids.last()) { + a.rebuild(&st.doc, *top); a.hide_in(&mut pd.layers); } - pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + // Several layers' opacity, fill, visibility and styles are in their composite: composite them + // again when an edit changed how they look. + if let Some(sv) = pv.several.as_mut() { + let now = Several::shown(&st.doc, &sv.ids, &sv.lifted); + if now.layers != sv.moving.layers { + sv.moving = now; + pv.texture = sv.render(); + } + } + if t.layers.is_empty() + && let Some(live) = st.doc.layer(LayerId(t.layer)) + { + pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + } follow.revision = st.revision; pv.doc = Arc::new(pd); } @@ -1247,6 +1485,17 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), + // One box over several layers doesn't warp (#6501). + "edit.transform.warp" + | "layer.smartObjects.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" + if !t.layers.is_empty() => + { + Some(false) + } // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). "edit.transform.distort" | "edit.transform.perspective" if moves_type(app, t) => Some(false), _ => None, @@ -1255,10 +1504,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Does the box move an editable type layer (not its selection outline, a lone mask or a path)? fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool { - !t.selection - && t.target.is_none() - && t.path.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + !t.selection && t.target.is_none() && t.path.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Edit › Transform's Rotate and Flip presets while a box is open: what they do to the box, as @@ -1456,8 +1702,9 @@ fn round_to_pixel_grid(app: &mut PhotocraftApp) { if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { return; } - let vector = - t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + let shapes = + |st: &photocraft_engine::DocState| moving_layers(t).iter().all(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))); + let vector = t.path.is_some() || app.session.active().is_some_and(shapes); if !vector || !upright(&t.quad) { return; } @@ -1501,10 +1748,7 @@ pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Mod /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool { - t.path.is_none() - && !t.selection - && t.target.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + t.path.is_none() && !t.selection && t.target.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a @@ -2133,7 +2377,7 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); - } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { + } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], texture_quad(t, pv)) { // A 24×24 grid keeps perspective previews straight. let n = 24; let zoom = xf.zoom * painter.ctx().pixels_per_point(); @@ -2170,6 +2414,18 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// Where the box puts the moving texture's corners: the box's own, or, when the texture covers +/// more than the frame (the styles of a box over several layers reach past it, [`Several`]), +/// where the frame's transform takes the texture's rect. +fn texture_quad(t: &TransformSession, pv: &TransformPreview) -> [[f64; 2]; 4] { + let Some(sv) = pv.several.as_ref().filter(|sv| sv.rect != sv.frame) else { return t.quad }; + let Some(h) = Homography::rect_to_quad(t.rect, t.quad) else { return t.quad }; + corners([sv.rect.x0, sv.rect.y0, sv.rect.x1, sv.rect.y1].map(f64::from)).map(|[x, y]| { + let (a, b) = h.apply(x, y); + [a, b] + }) +} + /// The box, handles, reference point and path outline over the preview that [`draw_content`] /// drew; the warp grid and its split guides for a warp. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { @@ -3129,6 +3385,7 @@ mod tests { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), } } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 0c1b4837ed..a07d134e13 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -128,6 +128,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }) } diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..94ef163d03 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-16 · **Change:** minor (G5: Free Transform of several selected layers, #6501; 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 @@ -190,7 +190,9 @@ what an agent can't finish alone. snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded - tools (#2672); H/V skew fields in the Free Transform options bar. + tools (#2672); H/V skew fields in the Free Transform options bar; Free Transform of several + selected layers, ⌘T and the Move tool's shared frame (#6501, #3384, #6096; Warp stays one + layer). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -270,6 +272,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G5: Free Transform of several selected layers (#6501): one box moves them all; no estimate changed | | 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/ui-design.md b/docs/ui-design.md index d4ac18d297..180e4a092c 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -54,7 +54,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Feature | Module | Behaviour | |---|---|---| | Type tool | `type_tool.rs` | Click: point text with the placeholder "Lorem Ipsum" selected. Drag: paragraph box. Inline caret and selection drawn from the text engine layout. ⌥/⌘ word and line navigation, ↩ newline, ⌘↩ or Esc commits, a click outside commits. One history step per session (`coalesce`). A new layer is named after its text; an empty one is deleted. | -| Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. | +| Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. With several layers selected one box frames and moves them all (`layers`; the Background is left out, Warp is one layer at a time). | | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..73f8f0eb56 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Free Transform of several selected layers, #6501; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -68,6 +68,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | | Options bar X/Y, W/H % with link, angle | done | | | With a selection, the box is the selection's rectangle and the marching ants follow the box live | done | `transform_frame`, `transform_tool.rs` (`draw_selection`): move, scale, rotate, skew, distort, perspective and warp, before the transform is applied; also the Move tool's Show Transform Controls (#5297) | +| Several layers selected: one box frames them all and moves every one (pixels, type, shapes, smart objects, groups), one history step; with a selection each pixel layer's selected pixels move; Transform Again repeats on them | partial | #6501, `transform_tool.rs` (`begin_several`), `transform_several_tests.rs`, engine `edit.transform` `layers` (`transform_multi_tests.rs`); the Background is left out, as Show Transform Controls leaves it; Warp and splits are greyed for several layers; the preview moves their styles past the frame and follows opacity, fill, visibility and style edits made while the box is open (`styles_past_the_frame_move_with_the_box`, `hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once`), but draws the layers stacked between the moving ones under them (the commit keeps every layer's place) | | Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` | | Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | | Commit/cancel buttons in the options bar | missing | #2442 | @@ -96,7 +97,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | -| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline, and its handles transform them all | done | `canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_that_transforms_them_all`; `move_auto_select_tests.rs`; #6501 | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Free Transform of several selected layers (#6501, #3384, #6096): ⌘T and the Move tool's shared frame move every selected layer in one step; row added as partial (Warp one layer at a time, preview stacks the in-between layers under the moving ones; styles past the frame and appearance edits while open show in the preview); Show Transform Controls row → done | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From bfc37773a89651132b86ee9124b4a8e728ca43c4 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 20:16:27 +0530 Subject: [PATCH 03/28] test(engine): Straighten Layer turns every selected layer, as in Photoshop Straighten's non-crop branch runs `edit.transform` without a layer, so since Free Transform takes the selected layers it turns all of them. That matches Photoshop ("only the selected layer(s) will straighten"); the test pins it, with one history step and one Undo, and checks that an unselected layer stays put. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/transform_multi_tests.rs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index d8f508f50b..c75e658779 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -207,3 +207,27 @@ fn bad_layers_params_are_errors_not_panics() { } assert!(s.active().unwrap().doc == before); } + +#[test] +fn straighten_layer_rotates_every_selected_layer() { + // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it + // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. + let (mut s, paint, text) = two_layers(); + let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert_eq!(r["cropped"], false); + assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); + assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + assert!(s.undo()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + + // With one layer selected, only that layer turns. + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(bounds(&s, paint) != p0); + assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); +} From b9847b3f9e301f5202cdc95a1c1de550cd8dd0e2 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 16:03:54 +0530 Subject: [PATCH 04/28] Save for Web: an on/off option that isn't true or false is an error (#6391) "lossless": "true" was read as absent, so the export silently wrote lossy WebP. Every on/off option (transparency, interlaced, progressive, lossless, optimized, embedIcc, convertToSrgb) now refuses anything but true, false or null, and the refused export writes and remembers nothing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 10 ++++++++-- crates/engine/src/web_cmds/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 561999461c..50fffb0165 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -181,7 +181,13 @@ impl WebSettings { if let Some(a) = p.get("ditherAmount").and_then(Value::as_f64) { st.dither_amount = (a as f32 / 100.0).clamp(0.0, 1.0); } - if let Some(b) = p.get("transparency").and_then(Value::as_bool) { + // An on/off option given as anything but true or false is an error, not its default. + let flag = |k: &str| match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{k}` must be true or false"))), + }; + if let Some(b) = flag("transparency")? { st.transparency = b; } match s("matte") { @@ -197,7 +203,7 @@ impl WebSettings { ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), ] { - if let Some(b) = p.get(k).and_then(Value::as_bool) { + if let Some(b) = flag(k)? { *slot = b; } } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 59f0c147ca..d027c8651f 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -343,6 +343,31 @@ fn webp_too_large_is_an_error() { } } +/// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise +/// write lossy WebP), and the refused export writes and remembers nothing. +#[test] +fn non_boolean_flags_are_refused_without_changing_anything() { + let dir = tmp("flags"); + let mut s = session(8); + let out = format!("{dir}/out.webp"); + let flags = ["transparency", "interlaced", "progressive", "lossless", "optimized", "embedIcc", "convertToSrgb"]; + for k in flags { + for v in [json!("true"), json!(1), json!([true])] { + let rev = s.active().unwrap().revision; + let mut p = json!({"format": "webp", "path": out}); + p[k] = v.clone(); + let err = s.execute("file.export.saveForWebLegacy", p).unwrap_err().to_string(); + assert!(err.contains(k), "{k}: {v}: {err}"); + assert_eq!(s.active().unwrap().revision, rev, "{k}: the document is untouched"); + assert!(!std::path::Path::new(&out).exists(), "{k}: nothing written"); + assert!(s.file_menu.last_web.is_none(), "{k}: nothing remembered"); + } + } + // null is the same as leaving it out. + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "path": out, "lossless": null})).unwrap(); + assert!(std::path::Path::new(&out).exists()); +} + #[test] fn slices_export_with_html_table() { let dir = tmp("slices"); From a83b2f848e21e68d81e4135234125b3fff1d40f8 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 09:19:05 +0530 Subject: [PATCH 05/28] review: a null layer beside layers is absent; Warp keeps several layers' box `edit.transform` refused {"layers": [...], "layer": null}, though `null` means "not given" everywhere else (explicit_layer). And the Warp button took the type-warp branch before enter_warp's several-layers guard, so with a type layer active among several it warped that layer alone while the preview moved them all: the guard now runs first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/transform_cmds.rs | 4 +++- crates/engine/src/transform_multi_tests.rs | 10 ++++++++++ crates/ui-egui/src/transform_several_tests.rs | 14 ++++++++++++++ crates/ui-egui/src/transform_tool.rs | 4 ++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 5b1bf00d19..e32a9a037f 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -789,7 +789,9 @@ fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; let many = layers_param(&st.doc, p)?; - if many.is_some() && (p.get("layer").is_some() || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) { + if many.is_some() + && (!matches!(p.get("layer"), None | Some(Value::Null)) || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) + { return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`")); } let explicit = crate::commands::explicit_layer(s, p)?; diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index c75e658779..ad34af7937 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -186,6 +186,16 @@ fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() { assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed"); } +#[test] +fn a_null_layer_beside_layers_counts_as_absent() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layers": [paint.0], "layer": null, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + #[test] fn bad_layers_params_are_errors_not_panics() { let (mut s, paint, _) = two_layers(); diff --git a/crates/ui-egui/src/transform_several_tests.rs b/crates/ui-egui/src/transform_several_tests.rs index 30330a5b6f..ee593e341f 100644 --- a/crates/ui-egui/src/transform_several_tests.rs +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -127,6 +127,20 @@ fn warp_is_greyed_and_distort_too_while_type_is_among_them() { assert!(!crate::menus::is_enabled(&app, "edit.transform.warp")); } +#[test] +fn the_warp_button_keeps_the_box_when_the_active_layer_is_type() { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(text)); + free_transform(&mut app); + let before = app.ui.transform.as_ref().map(|t| (t.rect, t.quad)); + crate::transform_tool::enter_warp(&mut app, &egui::Context::default()); + let t = app.ui.transform.as_ref().unwrap(); + assert!(t.warp.is_none(), "no warp on several layers"); + assert_eq!(Some((t.rect, t.quad)), before); +} + #[test] fn a_locked_layer_among_them_refuses_before_the_box_opens() { let (mut app, paint, text) = app(); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index ecfc14722f..1ddb05a40a 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -882,6 +882,10 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St /// custom mesh following the current box (so a transform made first carries over). Editable type /// gets its own Warp Text style instead ([`enter_type_warp`]). pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { + // Several layers keep the box (#6501), even when the active one is type. + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return; + } if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) { return enter_type_warp(app, ctx); } From a3b6850970d560669b3a3c1c990ad0d53aad7da0 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 09:19:10 +0530 Subject: [PATCH 06/28] review: Save for Web checks its settings before dropping a floating selection save_for_web_with_writer committed a floating move (a document edit) before WebSettings::from_params, so a bad on/off value like "lossless": "yes" failed after changing the document. Settings are checked first now; a valid export still drops the selection first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 3 ++- crates/engine/src/web_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index b2c0a025ef..136037c56b 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -941,10 +941,11 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u if let Some(authorize) = s.authorize { authorize(cmd, p)?; } + // Settings are checked first, so a refused export leaves a floating selection where it is. + let st = WebSettings::from_params(p, cmd)?; // Direct platform calls must export the moved selection as it appears on the canvas, // just as Session::execute does before dispatching the native command. crate::float_cmds::before_command(s, cmd)?; - let st = WebSettings::from_params(p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); let (wdoc, sx, sy) = web_document(&doc, p, &st)?; diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index a9a4543ea5..767d05f222 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -343,6 +343,25 @@ fn webp_too_large_is_an_error() { } } +#[test] +fn platform_writer_checks_settings_before_dropping_floating_pixels() { + let mut s = session(8); + s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap(); + s.execute("select.float", json!({"dx": 32})).unwrap(); + let rev = s.active().unwrap().revision; + let params = json!({"format": "webp", "path": "moved.webp", "lossless": "yes"}); + let mut output = Vec::new(); + let refused = save_for_web_with_writer(&mut s, ¶ms, &mut |_, bytes| { + output.extend_from_slice(bytes); + Ok(()) + }); + assert!(refused.unwrap_err().to_string().contains("lossless")); + assert!(output.is_empty()); + assert!(s.file_menu.last_web.is_none()); + assert_eq!(s.active().unwrap().revision, rev, "the document is untouched"); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some(), "a refused export must not commit a floating move"); +} + /// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise /// write lossy WebP), and the refused export writes and remembers nothing. #[test] From 52b829f848513b174bc6f1c89a221b83d94a81dc Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 23:21:17 +0530 Subject: [PATCH 07/28] Test: a floating piece among several layers checks the frame's layers, not the removed transform_controls_several Main's #7538 test asserted through transform_controls_several, which this branch removes now that several selected layers transform together (#6501). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UmLxt3PeAHUmPEQjSjYqj6 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 821abf7c97..8a118fba9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9382,7 +9382,7 @@ mod transform_controls_tests { app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; - assert!(transform_controls_several(&app), "two layers framed"); + assert_eq!(transform_controls_layers(&app).len(), 2, "two layers framed"); app.session.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some()); let xf = From 4e816dc0ca376cd33cfc8e1d746843b549b17031 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 11:04:33 +0530 Subject: [PATCH 08/28] feat(ui): Photoshop's Select and Mask workspace Select > Select and Mask... opens a full-window workspace laid out as Photoshop's, replacing the parameter dialog: - options bar: tool, add/subtract, Size, Hardness (Brush), Sample All Layers, Select Subject, Refine Hair (greyed: #2671) - tools: Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L (one slot), Hand H, Zoom Z; Shift+W / Shift+L cycle - the document under its tab, shown in the chosen view - Properties: Remember Settings, View Mode (view letters, Show Edge J, Show Original P, Real-Time Refinement, High Quality Preview, Transparency/Opacity, Overlay colour and Indicates), Refine Mode (Object Aware greyed: #2788), Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; Reset, OK, Cancel The tools become select.refineEdge `edits` (the Refine Edge Brush its `refineStrokes`): OK is one history step, Cancel drops everything, Ctrl+Z undoes per edit. The preview runs in the background on a reduced copy (full size with High Quality Preview), reusing the selection after the edits it already applied; Real-Time Refinement previews the stroke being drawn. A targeted layer mask is refined and output to the mask. Closes #7069. Closes #8148. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/distort_ui.rs | 30 +- crates/ui-egui/src/i18n/cs.tsv | 14 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 14 + crates/ui-egui/src/i18n/es.tsv | 14 + crates/ui-egui/src/i18n/fr.tsv | 14 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 14 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 14 + crates/ui-egui/src/i18n/ja.tsv | 15 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/nl.tsv | 14 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 14 + crates/ui-egui/src/i18n/ru.tsv | 14 + crates/ui-egui/src/i18n/sr.tsv | 14 + crates/ui-egui/src/i18n/tr.tsv | 14 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 15 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/layer_row_ui/edit_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/mask_props_ui_tests.rs | 31 +- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/select_mask_ui.rs | 1776 +++++++++++++++++ crates/ui-egui/src/select_mask_ui_tests.rs | 544 +++++ crates/ui-egui/src/select_mask_view.rs | 59 +- crates/ui-egui/src/shortcuts.rs | 6 +- docs/control-protocol.md | 1 + docs/gaps.md | 9 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 7 +- scorecard/tools.toml | 2 +- 36 files changed, 2750 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/select_mask_ui.rs create mode 100644 crates/ui-egui/src/select_mask_ui_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2543a54c2..9e1cbcc223 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2887,9 +2887,13 @@ fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_stri /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask /// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { + tab_title_at(app, i, app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0)) +} + +/// [`pro_tab_title`] at `zoom` percent (a workspace with its own view, such as Select and Mask). +pub(crate) fn tab_title_at(app: &PhotocraftApp, i: usize, zoom: f32) -> String { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.documents().get(i) else { return String::new() }; - let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); // Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free // Transform change the red overlay, so the tab says so, as Photoshop's does. let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite; diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 5441a971ee..fd97fda70a 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -24,6 +24,8 @@ pub struct Distort { pub liquify: Option, /// Edit › Content-Aware Fill… (caf_ui). pub caf: Option, + /// Select › Select and Mask… (select_mask_ui). + pub select_mask: Option, pub puppet: Option, pub perspective: Option, pub vanishing: Option, @@ -35,7 +37,12 @@ pub struct Distort { impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() + self.liquify.is_some() + || self.caf.is_some() + || self.select_mask.is_some() + || self.puppet.is_some() + || self.perspective.is_some() + || self.vanishing.is_some() } /// Short description for `ui.inspect`. @@ -43,6 +50,7 @@ impl Distort { json!({ "liquify": self.liquify.as_ref().map(|d| d.describe()), "contentAwareFill": self.caf.as_ref().map(|d| d.describe()), + "selectAndMask": self.select_mask.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), @@ -117,6 +125,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), // `ui` addresses the workspace: closed, that is an error, not a fill with the defaults. "edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())), + "select.selectAndMask" if ui.is_some() && app.distort.select_mask.is_some() => Some(crate::select_mask_ui::control(app, ui.unwrap_or(&Value::Null))), + "select.selectAndMask" if ui.is_some() => Some(Err("Select and Mask is not open".into())), + "select.selectAndMask" => Some(crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]}))), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } @@ -141,6 +152,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> crate::caf_ui::pointer(app, ev, mods); return true; } + if app.distort.select_mask.is_some() { + crate::select_mask_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.is_some() { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -182,6 +197,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.select_mask.is_some() { + // Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel. + if enter(ctx) { + crate::select_mask_ui::enter(app); + } else if esc(ctx) { + crate::select_mask_ui::escape(app); + } + return true; + } if app.distort.liquify.is_some() { if enter(ctx) { let _ = crate::liquify_ui::commit(app); @@ -285,6 +309,7 @@ pub fn drop_foreign(app: &mut PhotocraftApp) { app.distort.liquify = None; // Stops its background preview too; what Apply wrote stays, as with Cancel. crate::caf_ui::cancel(app); + crate::select_mask_ui::cancel(app); app.distort.puppet = None; app.distort.perspective = None; app.distort.vanishing = None; @@ -299,6 +324,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.caf.is_some() { crate::caf_ui::show(app, ctx); } + if app.distort.select_mask.is_some() { + crate::select_mask_ui::show(app, ctx); + } if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1e373d2792..23bcdee835 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4127,3 +4127,17 @@ cameraRaw Dehaze Odstranění oparu # Print preview image (#8236) Print preview image Print preview image + Color Aware Podle barev + Decontaminate Colors Dekontaminovat barvy + Edge Detection Detekce okraje + Global Refinements Globální upřesnění + High Quality Preview Náhled ve vysoké kvalitě + Object Aware Podle objektů + Real-Time Refinement Upřesnění v reálném čase + Refine Edge Brush Tool Nástroj štětec pro upřesnění okraje + Refine Hair Upřesnit vlasy + Refine Mode Režim upřesnění + Remember Settings Zapamatovat nastavení + View Mode Režim zobrazení + Object Aware needs a segmentation model, which isn't available yet Režim Podle objektů vyžaduje segmentační model, který zatím není k dispozici + Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5152f361ef..178a641461 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3393,3 +3393,18 @@ curves Smooth Glätten # Print preview image (#8236) Print preview image Print preview image + Clear Selection Auswahl löschen + Color Aware Farbbezogen + Decontaminate Colors Farben dekontaminieren + Edge Detection Kantenerkennung + Global Refinements Globale Verbesserungen + High Quality Preview Hochwertige Vorschau + Object Aware Objektbezogen + Real-Time Refinement Verbesserung in Echtzeit + Refine Edge Brush Tool Kante-verbessern-Pinsel-Werkzeug + Refine Hair Haare verbessern + Refine Mode Verbesserungsmodus + Remember Settings Einstellungen merken + View Mode Ansichtsmodus + Object Aware needs a segmentation model, which isn't available yet Objektbezogen benötigt ein Segmentierungsmodell, das noch nicht verfügbar ist + Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b72e1f8d2f..23fd2c3d7c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4082,3 +4082,17 @@ curves Smooth Εξομάλυνση # Print preview image (#8236) Print preview image Print preview image + Color Aware Με βάση το χρώμα + Decontaminate Colors Απολύμανση χρωμάτων + Edge Detection Ανίχνευση άκρης + Global Refinements Καθολικές βελτιώσεις + High Quality Preview Προεπισκόπηση υψηλής ποιότητας + Object Aware Με βάση το αντικείμενο + Real-Time Refinement Βελτίωση σε πραγματικό χρόνο + Refine Edge Brush Tool Εργαλείο πινέλου βελτίωσης ακμής + Refine Hair Βελτίωση μαλλιών + Refine Mode Λειτουργία βελτίωσης + Remember Settings Απομνημόνευση ρυθμίσεων + View Mode Λειτουργία προβολής + Object Aware needs a segmentation model, which isn't available yet Η λειτουργία Με βάση το αντικείμενο χρειάζεται μοντέλο τμηματοποίησης, που δεν είναι ακόμη διαθέσιμο + Refine Hair needs hair detection, which isn't available yet Η Βελτίωση μαλλιών χρειάζεται ανίχνευση μαλλιών, που δεν είναι ακόμη διαθέσιμη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 419dd0d452..f63f7b67bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4344,3 +4344,17 @@ navigator Zoom Zoom # Print preview image (#8236) Print preview image Print preview image + Color Aware Según color + Decontaminate Colors Descontaminar colores + Edge Detection Detección de bordes + Global Refinements Perfeccionamientos globales + High Quality Preview Previsualización de alta calidad + Object Aware Según objeto + Real-Time Refinement Perfeccionamiento en tiempo real + Refine Edge Brush Tool Herramienta Pincel para perfeccionar bordes + Refine Hair Perfeccionar pelo + Refine Mode Modo de perfeccionamiento + Remember Settings Recordar ajustes + View Mode Modo de visualización + Object Aware needs a segmentation model, which isn't available yet Según objeto necesita un modelo de segmentación que aún no está disponible + Refine Hair needs hair detection, which isn't available yet Perfeccionar pelo necesita detección de pelo, que aún no está disponible diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c3a1b800ac..26eac9d2c9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4424,3 +4424,17 @@ shape Appearance Apparence # Print preview image (#8236) Print preview image Print preview image + Color Aware Selon la couleur + Decontaminate Colors Décontaminer les couleurs + Edge Detection Détection des contours + Global Refinements Améliorations globales + High Quality Preview Aperçu haute qualité + Object Aware Selon l'objet + Real-Time Refinement Amélioration en temps réel + Refine Edge Brush Tool Outil Pinceau d'amélioration du contour + Refine Hair Améliorer les cheveux + Refine Mode Mode d'amélioration + Remember Settings Mémoriser les paramètres + View Mode Mode d'affichage + Object Aware needs a segmentation model, which isn't available yet Selon l'objet nécessite un modèle de segmentation, pas encore disponible + Refine Hair needs hair detection, which isn't available yet Améliorer les cheveux nécessite une détection des cheveux, pas encore disponible diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 34d749f777..131c5aa131 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -535,3 +535,18 @@ cameraRaw Yellow पीला Missing delete command हटाने का कमांड नहीं मिला Invalid delete command हटाने का कमांड अमान्य है Layer deleted, but confirmation preference could not be saved: {error} लेयर हटा दी गई, लेकिन पुष्टि की प्राथमिकता सहेजी नहीं जा सकी: {error} + Clear Selection चयन साफ़ करें + Color Aware रंग-आधारित + Decontaminate Colors रंग शुद्ध करें + Edge Detection किनारा पहचान + Global Refinements वैश्विक परिशोधन + High Quality Preview उच्च गुणवत्ता पूर्वावलोकन + Object Aware ऑब्जेक्ट-आधारित + Real-Time Refinement रियल-टाइम परिशोधन + Refine Edge Brush Tool किनारा परिशोधन ब्रश टूल + Refine Hair बाल परिशोधित करें + Refine Mode परिशोधन मोड + Remember Settings सेटिंग्स याद रखें + View Mode दृश्य मोड + Object Aware needs a segmentation model, which isn't available yet ऑब्जेक्ट-आधारित के लिए सेगमेंटेशन मॉडल चाहिए, जो अभी उपलब्ध नहीं है + Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 275de55bdb..e4c2ee2819 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4298,3 +4298,17 @@ cameraRaw Yellow Sárga # Print preview image (#8236) Print preview image Print preview image + Color Aware Színalapú + Decontaminate Colors Színek fertőtlenítése + Edge Detection Élészlelés + Global Refinements Globális finomítások + High Quality Preview Kiváló minőségű előnézet + Object Aware Objektumalapú + Real-Time Refinement Valós idejű finomítás + Refine Edge Brush Tool Élfinomító ecset eszköz + Refine Hair Haj finomítása + Refine Mode Finomítási mód + Remember Settings Beállítások megjegyzése + View Mode Nézetmód + Object Aware needs a segmentation model, which isn't available yet Az Objektumalapú módhoz szegmentálási modell kell, amely még nem érhető el + Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 51ffe4d8b9..50df853ffa 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3396,3 +3396,18 @@ curves Smooth Haluskan # Print preview image (#8236) Print preview image Print preview image + Clear Selection Hapus Seleksi + Color Aware Berbasis Warna + Decontaminate Colors Dekontaminasi Warna + Edge Detection Deteksi Tepi + Global Refinements Penyempurnaan Global + High Quality Preview Pratinjau Kualitas Tinggi + Object Aware Berbasis Objek + Real-Time Refinement Penyempurnaan Waktu Nyata + Refine Edge Brush Tool Alat Kuas Perhalus Tepi + Refine Hair Perhalus Rambut + Refine Mode Mode Penyempurnaan + Remember Settings Ingat Pengaturan + View Mode Mode Tampilan + Object Aware needs a segmentation model, which isn't available yet Berbasis Objek memerlukan model segmentasi, yang belum tersedia + Refine Hair needs hair detection, which isn't available yet Perhalus Rambut memerlukan deteksi rambut, yang belum tersedia diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3c65c5a25f..d92cd347ba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4499,3 +4499,17 @@ shape Appearance Aspetto # Print preview image (#8236) Print preview image Print preview image + Color Aware In base al colore + Decontaminate Colors Decontamina colori + Edge Detection Rilevamento bordi + Global Refinements Perfezionamenti globali + High Quality Preview Anteprima di alta qualità + Object Aware In base all'oggetto + Real-Time Refinement Perfezionamento in tempo reale + Refine Edge Brush Tool Strumento Pennello per migliorare il bordo + Refine Hair Migliora capelli + Refine Mode Metodo di perfezionamento + Remember Settings Ricorda impostazioni + View Mode Modalità di visualizzazione + Object Aware needs a segmentation model, which isn't available yet In base all'oggetto richiede un modello di segmentazione, non ancora disponibile + Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9018c00df..f12797d444 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4060,3 +4060,18 @@ curves Smooth スムーズ # Print preview image (#8236) Print preview image Print preview image + Clear Selection 選択範囲をクリア + Color Aware カラー認識 + Decontaminate Colors 不要なカラーの除去 + Edge Detection エッジの検出 + Global Refinements グローバル調整 + High Quality Preview 高品質プレビュー + Object Aware オブジェクト認識 + Real-Time Refinement リアルタイム調整 + Refine Edge Brush Tool 境界線調整ブラシツール + Refine Hair 髪の毛を調整 + Refine Mode 調整モード + Remember Settings 設定を保存 + View Mode 表示モード + Object Aware needs a segmentation model, which isn't available yet オブジェクト認識にはセグメンテーションモデルが必要ですが、まだ利用できません + Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c70cf5fb1c..b5d2c04ede 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3591,3 +3591,18 @@ curves Smooth 매끄럽게 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 선택 영역 지우기 + Color Aware 색상 인식 + Decontaminate Colors 색상 정화 + Edge Detection 가장자리 감지 + Global Refinements 전역 다듬기 + High Quality Preview 고품질 미리 보기 + Object Aware 개체 인식 + Real-Time Refinement 실시간 다듬기 + Refine Edge Brush Tool 가장자리 다듬기 브러시 도구 + Refine Hair 머리카락 다듬기 + Refine Mode 다듬기 모드 + Remember Settings 설정 기억 + View Mode 보기 모드 + Object Aware needs a segmentation model, which isn't available yet 개체 인식에는 분할 모델이 필요하지만 아직 사용할 수 없습니다 + Refine Hair needs hair detection, which isn't available yet 머리카락 다듬기에는 머리카락 감지가 필요하지만 아직 사용할 수 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20de061389..fe8cbe2a46 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4142,3 +4142,17 @@ curves Smooth Vloeiend maken # Print preview image (#8236) Print preview image Print preview image + Color Aware Kleurbewust + Decontaminate Colors Kleuren zuiveren + Edge Detection Randdetectie + Global Refinements Globale verfijningen + High Quality Preview Voorvertoning in hoge kwaliteit + Object Aware Objectbewust + Real-Time Refinement Verfijning in realtime + Refine Edge Brush Tool Gereedschap Penseel Rand verfijnen + Refine Hair Haar verfijnen + Refine Mode Verfijningsmodus + Remember Settings Instellingen onthouden + View Mode Weergavemodus + Object Aware needs a segmentation model, which isn't available yet Objectbewust vereist een segmentatiemodel dat nog niet beschikbaar is + Refine Hair needs hair detection, which isn't available yet Haar verfijnen vereist haardetectie die nog niet beschikbaar is diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b66b543d74..9a6df5484a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3998,3 +3998,18 @@ curves Smooth Wygładź # Print preview image (#8236) Print preview image Print preview image + Clear Selection Wyczyść zaznaczenie + Color Aware Według kolorów + Decontaminate Colors Odkażanie kolorów + Edge Detection Wykrywanie krawędzi + Global Refinements Dopracowanie globalne + High Quality Preview Podgląd wysokiej jakości + Object Aware Według obiektów + Real-Time Refinement Dopracowanie w czasie rzeczywistym + Refine Edge Brush Tool Narzędzie Pędzel dopracowania krawędzi + Refine Hair Dopracuj włosy + Refine Mode Tryb dopracowania + Remember Settings Zapamiętaj ustawienia + View Mode Tryb widoku + Object Aware needs a segmentation model, which isn't available yet Tryb Według obiektów wymaga modelu segmentacji, który nie jest jeszcze dostępny + Refine Hair needs hair detection, which isn't available yet Dopracowanie włosów wymaga wykrywania włosów, które nie jest jeszcze dostępne diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e1195ea059..a76443d5f9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4321,3 +4321,17 @@ layerLabel No Color Sem cor # Print preview image (#8236) Print preview image Print preview image + Color Aware Com reconhecimento de cor + Decontaminate Colors Descontaminar cores + Edge Detection Detecção de borda + Global Refinements Refinamentos globais + High Quality Preview Visualização de alta qualidade + Object Aware Com reconhecimento de objeto + Real-Time Refinement Refinamento em tempo real + Refine Edge Brush Tool Ferramenta Pincel de refinamento de borda + Refine Hair Refinar cabelo + Refine Mode Modo de refinamento + Remember Settings Lembrar configurações + View Mode Modo de exibição + Object Aware needs a segmentation model, which isn't available yet Com reconhecimento de objeto requer um modelo de segmentação, ainda não disponível + Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 66bc4d128e..dd4cd5eda8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4587,3 +4587,17 @@ shape Appearance Оформление # Print preview image (#8236) Print preview image Print preview image + Color Aware С учетом цвета + Decontaminate Colors Очистить цвета + Edge Detection Обнаружение краев + Global Refinements Глобальное уточнение + High Quality Preview Высококачественный просмотр + Object Aware С учетом объектов + Real-Time Refinement Уточнение в реальном времени + Refine Edge Brush Tool Инструмент «Кисть уточнения края» + Refine Hair Уточнить волосы + Refine Mode Режим уточнения + Remember Settings Запомнить параметры + View Mode Режим просмотра + Object Aware needs a segmentation model, which isn't available yet Для режима «С учетом объектов» нужна модель сегментации, которая пока недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнения волос нужно распознавание волос, которое пока недоступно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f071ee5399..925aec44f4 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4454,3 +4454,17 @@ shape Appearance Izgled # Print preview image (#8236) Print preview image Print preview image + Color Aware Prema boji + Decontaminate Colors Dekontaminiraj boje + Edge Detection Detekcija ivice + Global Refinements Globalna doterivanja + High Quality Preview Pregled visokog kvaliteta + Object Aware Prema objektu + Real-Time Refinement Doterivanje u realnom vremenu + Refine Edge Brush Tool Alatka četkica za doterivanje ivice + Refine Hair Doteraj kosu + Refine Mode Režim doterivanja + Remember Settings Zapamti podešavanja + View Mode Režim prikaza + Object Aware needs a segmentation model, which isn't available yet Režim Prema objektu zahteva model segmentacije, koji još nije dostupan + Refine Hair needs hair detection, which isn't available yet Doterivanje kose zahteva detekciju kose, koja još nije dostupna diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 90fe0c3eef..41100af634 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4112,3 +4112,17 @@ cameraData Lens Lens # Print preview image (#8236) Print preview image Print preview image + Color Aware Renge Duyarlı + Decontaminate Colors Renkleri Arındır + Edge Detection Kenar Algılama + Global Refinements Genel Hassaslaştırmalar + High Quality Preview Yüksek Kaliteli Önizleme + Object Aware Nesneye Duyarlı + Real-Time Refinement Gerçek Zamanlı Hassaslaştırma + Refine Edge Brush Tool Kenarı Hassaslaştırma Fırçası Aracı + Refine Hair Saçı Hassaslaştır + Refine Mode Hassaslaştırma Modu + Remember Settings Ayarları Hatırla + View Mode Görünüm Modu + Object Aware needs a segmentation model, which isn't available yet Nesneye Duyarlı mod henüz kullanılamayan bir bölütleme modeli gerektirir + Refine Hair needs hair detection, which isn't available yet Saçı Hassaslaştır henüz kullanılamayan saç algılama gerektirir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 56ad5e718a..684dd72fb6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4091,3 +4091,18 @@ curves Smooth Згладити # Print preview image (#8236) Print preview image Print preview image + Clear Selection Очистити виділення + Color Aware З урахуванням кольору + Decontaminate Colors Очистити кольори + Edge Detection Виявлення краю + Global Refinements Глобальне уточнення + High Quality Preview Високоякісний перегляд + Object Aware З урахуванням об'єктів + Real-Time Refinement Уточнення в реальному часі + Refine Edge Brush Tool Інструмент «Пензель уточнення краю» + Refine Hair Уточнити волосся + Refine Mode Режим уточнення + Remember Settings Запам'ятати параметри + View Mode Режим перегляду + Object Aware needs a segmentation model, which isn't available yet Для режиму «З урахуванням об'єктів» потрібна модель сегментації, яка ще недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 65f866e486..9968728ce2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3874,3 +3874,18 @@ curves Smooth Làm mượt # Print preview image (#8236) Print preview image Print preview image + Clear Selection Xóa vùng chọn + Color Aware Nhận biết màu + Decontaminate Colors Khử nhiễm màu + Edge Detection Phát hiện cạnh + Global Refinements Tinh chỉnh tổng thể + High Quality Preview Xem trước chất lượng cao + Object Aware Nhận biết đối tượng + Real-Time Refinement Tinh chỉnh theo thời gian thực + Refine Edge Brush Tool Công cụ cọ tinh chỉnh cạnh + Refine Hair Tinh chỉnh tóc + Refine Mode Chế độ tinh chỉnh + Remember Settings Ghi nhớ cài đặt + View Mode Chế độ xem + Object Aware needs a segmentation model, which isn't available yet Nhận biết đối tượng cần mô hình phân đoạn, hiện chưa có + Refine Hair needs hair detection, which isn't available yet Tinh chỉnh tóc cần tính năng phát hiện tóc, hiện chưa có diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 16cd47edf8..edb6d3d3fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3041,3 +3041,18 @@ curves Smooth 平滑 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 清除选区 + Color Aware 颜色识别 + Decontaminate Colors 净化颜色 + Edge Detection 边缘检测 + Global Refinements 全局调整 + High Quality Preview 高品质预览 + Object Aware 对象识别 + Real-Time Refinement 实时调整 + Refine Edge Brush Tool 调整边缘画笔工具 + Refine Hair 调整细线 + Refine Mode 调整模式 + Remember Settings 记住设置 + View Mode 视图模式 + Object Aware needs a segmentation model, which isn't available yet 对象识别需要分割模型,目前尚不可用 + Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7350d24ba9..6bb4891e54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3005,3 +3005,18 @@ curves Smooth 平滑 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 清除選取範圍 + Color Aware 顏色感知 + Decontaminate Colors 淨化顏色 + Edge Detection 邊緣偵測 + Global Refinements 整體調整 + High Quality Preview 高品質預視 + Object Aware 物件感知 + Real-Time Refinement 即時調整 + Refine Edge Brush Tool 調整邊緣筆刷工具 + Refine Hair 調整毛髮 + Refine Mode 調整模式 + Remember Settings 記住設定 + View Mode 檢視模式 + Object Aware needs a segmentation model, which isn't available yet 物件感知需要分割模型,目前尚無法使用 + Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用 diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 0c217dd3d0..65496cbe30 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -153,8 +153,8 @@ fn mask_double_click_launches_select_and_mask_with_preference() { let at = masks.iter().find(|(kind, _)| *kind == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); double_click(&mut h, at); assert!(h.state().ui.mask_target); - let dialog = h.state().ui.dialogs.last().expect("select and mask dialog should be opened"); - assert_eq!(dialog.fields.get("__command").and_then(|v| v.as_str()), Some("select.refineEdge")); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "the Select and Mask workspace opens on the mask: {w}"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ea5992d43..96f0cf75f6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,7 @@ pub mod rulers; pub mod screen_picker; pub mod scrollbars; mod scrub_wrap; +pub mod select_mask_ui; pub mod select_mask_view; pub mod served_fonts; pub mod shape_dialog; diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 782337ed36..0fcc9de195 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -293,21 +293,22 @@ fn refine_invert_inverts_the_mask_and_leaves_the_layer_alone() { } #[test] -fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { - for (label, open) in [("Select and Mask…", "select.refineEdge"), ("Color Range…", crate::color_range_ui::COMMAND)] { - let mut h = harness(false, crate::theme::ThemeKind::Pro); - assert!(h.state().ui.dialogs.is_empty()); - h.get_by_label(label).click(); - h.run_steps(3); - let dialogs = &h.state().ui.dialogs; - assert_eq!(dialogs.len(), 1, "{label} opens one dialog"); - let fields = &dialogs[0].fields; - let opened = fields.get("__command").and_then(Value::as_str).unwrap_or(crate::color_range_ui::COMMAND); - assert_eq!(opened, open, "{label} opened {opened}"); - if open == crate::color_range_ui::COMMAND { - assert!(crate::color_range_ui::owns(fields), "the Color Range dialog, not the generic one"); - } - } +fn refine_row_opens_the_select_and_mask_workspace_and_color_range_dialog() { + // Select and Mask… opens Photoshop's workspace on the targeted mask. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.get_by_label("Select and Mask…").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "a workspace, not a dialog"); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "it refines the mask: {w}"); + // Color Range… opens its own dialog. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + assert!(h.state().ui.dialogs.is_empty()); + h.get_by_label("Color Range…").click(); + h.run_steps(3); + let dialogs = &h.state().ui.dialogs; + assert_eq!(dialogs.len(), 1, "Color Range… opens one dialog"); + assert!(crate::color_range_ui::owns(&dialogs[0].fields), "the Color Range dialog, not the generic one"); } /// A Studio-layout theme's floating Properties card, with no mask targeted. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57b15fb8ad..f27e5bb634 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -492,7 +492,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(json!({"dialog": crate::fill_ui::open(app)})) } - "select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})), + // Photoshop's workspace (select_mask_ui); `distort_ui::menu` routes it first. + "select.selectAndMask" => crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]})), // Select › Transform Selection from the menu: the interactive box (with params: the engine). "select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_selection(app, ctx).map(|_| json!({"transform": app.ui.transform})) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs new file mode 100644 index 0000000000..baf06a4a9f --- /dev/null +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -0,0 +1,1776 @@ +//! Select › Select and Mask…: Photoshop's Select and Mask workspace (#7069, #8148, TOOL-214-1), a +//! full-window workspace like Content-Aware Fill's. +//! +//! Laid out as Photoshop's: the tool's options bar across the top, the tools on the left (Quick +//! Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L in +//! one slot, Hand H, Zoom Z; Shift+W and Shift+L cycle), the document under its tab in the middle, +//! shown in the chosen view, and the Properties panel on the right: Remember Settings, View Mode +//! (the views with their letters, Show Edge J, Show Original P, Real-Time Refinement, High Quality +//! Preview, Transparency or Opacity, Overlay's colour and what it indicates), Refine Mode, Edge +//! Detection, Global Refinements (with Clear Selection and Invert) and Output Settings, then +//! Reset, OK and Cancel. The view keys (O M V A T K Y, F cycles, hold X for the image) work as in +//! the dialog this replaces. +//! +//! What the workspace holds is plain data: the tools' edits (`select.refineEdge`'s `edits`), the +//! Refine Edge Brush strokes (`refineStrokes`) and the settings. OK runs `select.refineEdge` with +//! them, one history step; Cancel drops them; Ctrl+Z undoes the last edit or stroke. The preview +//! runs the same edits ([`photocraft_engine::select_mask_edits`]) on a reduced copy (full size +//! with High Quality Preview) in the background and keeps the selection after the edits it has +//! applied, so a new stroke costs only that stroke; then it shows the refined selection in the +//! view ([`crate::select_mask_view::preview_proxy`]). With Real-Time Refinement the stroke being +//! drawn joins the preview while the pointer moves; without, when it is released. +//! +//! Not here yet: Refine Hair (#2671) and Object Aware (a learned model, #2788) are shown but greyed +//! out, and Photoshop's Presets. + +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; +use photocraft_doc::{Document, LayerId}; +use photocraft_engine::select_mask_edits::{self, MAX_EDIT_POINTS, MAX_EDITS}; +use photocraft_engine::smartselect_cmds::{MAX_STROKE_POINTS, MAX_STROKES}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::select_mask_view::Mode; +use crate::theme::Tokens; +use crate::widgets; + +/// Longest side of the reduced copy the preview runs on (px), unless High Quality Preview. +const PROXY_SIDE: usize = 1600; +const LEFT_W: f32 = 48.0; +const BAR_H: f32 = 36.0; +const TAB_H: f32 = 28.0; +const PANEL_W: f32 = 350.0; +const UNDO_LIMIT: usize = 100; +/// Marching ants with more edges than this are not drawn. +const MAX_ANT_EDGES: usize = 200_000; +/// The `prefs.dialogs` key under which Remember Settings keeps the settings. +const REMEMBERED: &str = "select.refineEdge.workspace"; +const CMD: &str = crate::select_mask_view::COMMAND; +/// The Output To targets, in Photoshop's order. +const OUTPUTS: [&str; 6] = ["selection", "layerMask", "newLayer", "newLayerWithMask", "newDocument", "newDocumentWithMask"]; +/// Photoshop's brush sizes (px). +const SIZES: std::ops::RangeInclusive = 1.0..=5000.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmTool { + Quick, + RefineEdge, + Brush, + Object, + Lasso, + PolygonalLasso, + Hand, + Zoom, +} + +impl SmTool { + fn name(self) -> &'static str { + match self { + SmTool::Quick => "quick", + SmTool::RefineEdge => "refineEdge", + SmTool::Brush => "brush", + SmTool::Object => "object", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "polygonalLasso", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom", + } + } + + fn from_name(s: &str) -> Option { + Some(match s { + "quick" => SmTool::Quick, + "refineEdge" => SmTool::RefineEdge, + "brush" => SmTool::Brush, + "object" => SmTool::Object, + "lasso" => SmTool::Lasso, + "polygonalLasso" => SmTool::PolygonalLasso, + "hand" => SmTool::Hand, + "zoom" => SmTool::Zoom, + _ => return None, + }) + } + + fn icon(self) -> &'static str { + match self { + SmTool::Quick => "quick-selection", + SmTool::RefineEdge => "brush-cleaning", + SmTool::Brush => "brush", + SmTool::Object => "square-dashed-mouse-pointer", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "pentagon", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom-in", + } + } + + fn tip(self) -> String { + let (label, key) = match self { + SmTool::Quick => (tl!("Quick Selection Tool"), "W"), + SmTool::RefineEdge => (tl!("Refine Edge Brush Tool"), "R"), + SmTool::Brush => (tl!("Brush Tool"), "B"), + SmTool::Object => (tl!("Object Selection Tool"), "W"), + SmTool::Lasso => (tl!("Lasso Tool"), "L"), + SmTool::PolygonalLasso => (tl!("Polygonal Lasso Tool"), "L"), + SmTool::Hand => (tl!("Hand Tool"), "H"), + SmTool::Zoom => (tl!("Zoom Tool"), "Z"), + }; + format!("{label} ({key})") + } + + /// Paints with a round brush (size from `[` and `]`). + fn brushes(self) -> bool { + matches!(self, SmTool::Quick | SmTool::RefineEdge | SmTool::Brush) + } + + /// Adds to or subtracts from the selection (the options bar's + and −; Alt swaps them). + fn edits(self) -> bool { + !matches!(self, SmTool::Hand | SmTool::Zoom) + } +} + +/// The Properties panel's settings (kept between uses with Remember Settings). +#[derive(Clone, Debug, PartialEq)] +pub struct SmOpts { + pub view: Mode, + /// Each view's Transparency / Opacity (percent), in [`Mode::ALL`] order. + pub opacity: [f32; 7], + pub show_edge: bool, + pub show_original: bool, + /// Real-Time Refinement: the stroke being drawn joins the preview while the pointer moves. + pub real_time: bool, + /// High Quality Preview: the preview runs on the document at full size. + pub high_quality: bool, + /// Overlay's colour and whether it marks the selected areas (else the masked ones). + pub overlay_color: [u8; 3], + pub indicates_selected: bool, + pub radius: f32, + pub smart_radius: bool, + pub smooth: f32, + pub feather: f32, + pub contrast: f32, + pub shift_edge: f32, + pub decontaminate: bool, + pub amount: f32, + pub output: String, + pub remember: bool, +} + +impl Default for SmOpts { + fn default() -> Self { + SmOpts { + view: Mode::OnionSkin, + opacity: Mode::ALL.map(|m| m.default_opacity().unwrap_or(100.0)), + show_edge: false, + show_original: false, + real_time: true, + high_quality: false, + overlay_color: [255, 0, 0], + indicates_selected: false, + radius: 0.0, + smart_radius: false, + smooth: 0.0, + feather: 0.0, + contrast: 0.0, + shift_edge: 0.0, + decontaminate: false, + amount: 100.0, + output: "selection".into(), + remember: false, + } + } +} + +fn mode_index(m: Mode) -> usize { + Mode::ALL.iter().position(|x| *x == m).unwrap_or(0) +} + +impl SmOpts { + /// Applies the settings named in `v` (control channel and remembered keys); others stay. + pub fn apply(&mut self, v: &Value) -> Result<(), String> { + let flag = |k: &str| v.get(k).and_then(Value::as_bool); + for (k, slot) in [ + ("showEdge", &mut self.show_edge), + ("showOriginal", &mut self.show_original), + ("realTimeRefinement", &mut self.real_time), + ("highQualityPreview", &mut self.high_quality), + ("smartRadius", &mut self.smart_radius), + ("decontaminate", &mut self.decontaminate), + ("remember", &mut self.remember), + ] { + if let Some(b) = flag(k) { + *slot = b; + } + } + for (k, slot, lo, hi) in [ + ("radius", &mut self.radius, 0.0, 250.0), + ("smooth", &mut self.smooth, 0.0, 100.0), + ("feather", &mut self.feather, 0.0, 1000.0), + ("contrast", &mut self.contrast, 0.0, 100.0), + ("shiftEdge", &mut self.shift_edge, -100.0, 100.0), + ("amount", &mut self.amount, 0.0, 100.0), + ] { + if let Some(n) = v.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()) { + *slot = (n as f32).clamp(lo, hi); + } + } + if let Some(m) = v.get("view").and_then(Value::as_str) { + self.view = Mode::from_id(m).ok_or_else(|| format!("bad view `{m}` ({})", Mode::ALL.map(|m| m.id()).join("|")))?; + } + match v.get("opacity") { + // A number: the current view's; an object: per view. + Some(Value::Number(n)) => { + if let Some(n) = n.as_f64().filter(|n| n.is_finite()) { + self.opacity[mode_index(self.view)] = (n as f32).clamp(0.0, 100.0); + } + } + Some(Value::Object(o)) => { + for (k, n) in o { + if let (Some(m), Some(n)) = (Mode::from_id(k), n.as_f64().filter(|n| n.is_finite())) { + self.opacity[mode_index(m)] = (n as f32).clamp(0.0, 100.0); + } + } + } + _ => {} + } + if let Some(c) = v.get("overlayColor").and_then(Value::as_array).filter(|c| c.len() == 3) { + let ch = |i: usize| c.get(i).and_then(Value::as_u64).map(|v| v.min(255) as u8); + if let (Some(r), Some(g), Some(b)) = (ch(0), ch(1), ch(2)) { + self.overlay_color = [r, g, b]; + } + } + match v.get("indicates").and_then(Value::as_str) { + None => {} + Some("masked") => self.indicates_selected = false, + Some("selected") => self.indicates_selected = true, + Some(s) => return Err(format!("bad indicates `{s}` (masked|selected)")), + } + if let Some(o) = v.get("output").and_then(Value::as_str) { + if !OUTPUTS.contains(&o) { + return Err(format!("bad output `{o}` ({})", OUTPUTS.join("|"))); + } + self.output = o.to_string(); + } + self.follow_decontaminate(); + Ok(()) + } + + /// Decontaminating needs new pixels, so (as in Photoshop) it outputs to a new layer or document. + fn follow_decontaminate(&mut self) { + if self.decontaminate && matches!(self.output.as_str(), "selection" | "layerMask") { + self.output = "newLayerWithMask".into(); + } + } + + /// The settings as [`SmOpts::apply`] reads them. + pub fn to_json(&self) -> Value { + let opacity: Map = Mode::ALL.iter().zip(self.opacity).map(|(m, o)| (m.id().to_string(), json!(o))).collect(); + json!({ + "view": self.view.id(), + "opacity": opacity, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "realTimeRefinement": self.real_time, + "highQualityPreview": self.high_quality, + "overlayColor": self.overlay_color, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + "radius": self.radius, + "smartRadius": self.smart_radius, + "smooth": self.smooth, + "feather": self.feather, + "contrast": self.contrast, + "shiftEdge": self.shift_edge, + "decontaminate": self.decontaminate, + "amount": self.amount, + "output": self.output, + "remember": self.remember, + }) + } + + /// `select.refineEdge`'s refinement and output params. + fn refine_params(&self) -> Map { + let mut p = Map::new(); + p.insert("radius".into(), json!(self.radius)); + p.insert("smartRadius".into(), json!(self.smart_radius)); + p.insert("smooth".into(), json!(self.smooth)); + p.insert("feather".into(), json!(self.feather)); + p.insert("contrast".into(), json!(self.contrast)); + p.insert("shiftEdge".into(), json!(self.shift_edge)); + p.insert("decontaminate".into(), json!(self.decontaminate)); + p.insert("amount".into(), json!(self.amount)); + p.insert("output".into(), json!(self.output)); + p + } + + /// The preview's view (`select_mask_view`'s `__view`). + fn view_params(&self) -> Value { + let c = self.overlay_color.map(|v| f32::from(v) / 255.0); + json!({ + "mode": self.view.id(), + "opacity": self.opacity[mode_index(self.view)] / 100.0, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "color": c, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + }) + } +} + +/// One undoable step. +#[derive(Clone, Debug)] +enum Step { + Edit(Value), + Stroke(Value), + /// A reset, with what it replaced. + State { + edits: Vec, + strokes: Vec, + opts: Box, + }, +} + +/// A press-and-drag on the view with a tool, in document px. +#[derive(Clone, Debug)] +struct Gesture { + tool: SmTool, + add: bool, + points: Vec<[f64; 2]>, +} + +/// The selection after the first `edits` (document coordinates) on the copy reduced by `k`. +#[derive(Clone)] +struct BaseCache { + k: u32, + edits: Vec, + base: Option, +} + +/// A computed preview, ready to upload. +struct Shown { + image: egui::ColorImage, + /// Marching ants (document px) in the Marching Ants view. + ants: Option>, + /// Reduced-copy pixels at least half selected. + selected: usize, + /// How many points of the stroke being drawn it includes ([`usize::MAX`]: every stroke is in). + trail: usize, + /// The document px the image covers (the reduced copy's size × `k`). + covers: [f32; 2], +} + +#[cfg(not(target_arch = "wasm32"))] +type Outcome = Result<(Shown, BaseCache, Arc), String>; + +/// The preview being computed or shown. +#[derive(Default)] +struct Preview { + /// Bumped whenever the state changes; a result for an older generation is dropped. + want: u64, + #[cfg(not(target_arch = "wasm32"))] + running: Option<(u64, u64, std::sync::mpsc::Receiver, photocraft_engine::jobs::JobCtx)>, + shown: u64, + pending: Option, + tex: Option, + ants: Option>, + covers: [f32; 2], + selected: usize, + /// [`Shown::trail`] of the preview shown. + trail: usize, + cache: Option, + /// The reduced copy of the document (its factor and the revision it was made from). + proxy: Option<(u32, u64, Arc)>, + error: Option, +} + +pub struct SelectMaskWorkspace { + layer: Option, + doc_index: usize, + canvas: Rect, + /// The document revision the textures were made from. + revision: u64, + /// The targeted layer mask is what is refined (`"target": "mask"`). + target_mask: bool, + pub opts: SmOpts, + pub tool: SmTool, + /// The tools W and L pick: the last of each slot used. + w_tool: SmTool, + l_tool: SmTool, + pub add: bool, + pub quick_size: f64, + pub refine_size: f64, + pub brush_size: f64, + pub hardness: f32, + pub sample_all: bool, + pub edits: Vec, + pub strokes: Vec, + undo: Vec, + redo: Vec, + gesture: Option, + /// The stroke last drawn, until a preview with it arrives (its newest part is drawn over the + /// preview meanwhile, so painting looks continuous). + trail: Option, + /// The Polygonal Lasso's vertices so far, and whether it adds. + poly: Option<(bool, Vec<[f64; 2]>)>, + /// X is held: the image without the view. + hide_view: bool, + /// Document px per reduced-copy px (High Quality Preview uses 1). + k: u32, + pressed: bool, + /// The device's texture side limit, as the context reports it each frame. + texture_limit: usize, + image_tex: Option, + zoom: f32, + center: [f64; 2], + hover: Option<[f64; 2]>, + preview: Preview, + pub error: Option, +} + +impl SelectMaskWorkspace { + pub fn describe(&self) -> Value { + json!({ + "tool": self.tool.name(), + "mode": if self.add { "add" } else { "subtract" }, + "size": self.size(), + "hardness": self.hardness, + "sampleAllLayers": self.sample_all, + "edits": self.edits.len(), + "strokes": self.strokes.len(), + "undo": self.undo.len(), + "redo": self.redo.len(), + "polygon": self.poly.as_ref().map_or(0, |(_, p)| p.len()), + "settings": self.opts.to_json(), + "proxyScale": self.proxy_k(), + "selectedProxyPixels": self.preview.selected, + "previewReady": (self.preview.tex.is_some() || self.preview.pending.is_some()) && self.preview.shown == self.preview.want, + "previewError": self.preview.error, + "error": self.error, + }) + } + + /// The factor the preview runs at. + fn proxy_k(&self) -> u32 { + if self.opts.high_quality { 1 } else { self.k } + } + + /// The current brush tool's size (Quick Selection, Refine Edge Brush, Brush; else the brush's). + fn size(&self) -> f64 { + match self.tool { + SmTool::Quick => self.quick_size, + SmTool::RefineEdge => self.refine_size, + _ => self.brush_size, + } + } + + fn set_size(&mut self, v: f64) { + let v = if v.is_finite() { v.round().clamp(*SIZES.start(), *SIZES.end()) } else { return }; + match self.tool { + SmTool::Quick => self.quick_size = v, + SmTool::RefineEdge => self.refine_size = v, + SmTool::Brush => self.brush_size = v, + _ => {} + } + } + + fn pick(&mut self, t: SmTool) { + self.tool = t; + match t { + SmTool::Quick | SmTool::Object => self.w_tool = t, + SmTool::Lasso | SmTool::PolygonalLasso => self.l_tool = t, + _ => {} + } + if t != SmTool::PolygonalLasso { + self.poly = None; + } + } + + /// The preview is out of date. + fn stale(&mut self) { + self.preview.want += 1; + } + + fn push(&mut self, s: Step) { + self.redo.clear(); + if self.undo.len() == UNDO_LIMIT { + self.undo.remove(0); + } + self.undo.push(s); + } + + fn points_in(list: &[Value]) -> usize { + list.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum() + } + + /// Adds a tool edit (undoable), within the engine's limits. + fn add_edit(&mut self, e: Value) { + if self.edits.len() >= MAX_EDITS { + self.error = Some(format!("at most {MAX_EDITS} edits: undo some, or reset")); + return; + } + if Self::points_in(&self.edits) + Self::points_in(std::slice::from_ref(&e)) > MAX_EDIT_POINTS { + self.error = Some(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); + return; + } + self.error = None; + self.edits.push(e.clone()); + self.push(Step::Edit(e)); + self.stale(); + } + + /// Adds a Refine Edge Brush stroke (undoable), within the engine's limits. + fn add_stroke(&mut self, s: Value) { + if self.strokes.len() >= MAX_STROKES { + self.error = Some(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); + return; + } + if Self::points_in(&self.strokes) + Self::points_in(std::slice::from_ref(&s)) > MAX_STROKE_POINTS { + self.error = Some(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); + return; + } + self.error = None; + self.strokes.push(s.clone()); + self.push(Step::Stroke(s)); + self.stale(); + } + + /// What `g` adds when it ends: a tool edit or a Refine Edge Brush stroke (`None`: nothing). + fn finish(&self, g: &Gesture) -> Option> { + let round = |p: &[f64; 2]| json!([(p[0] * 10.0).round() / 10.0, (p[1] * 10.0).round() / 10.0]); + let pts: Vec = g.points.iter().map(round).collect(); + let mode = if g.add { "add" } else { "subtract" }; + let first = g.points.first()?; + Some(match g.tool { + SmTool::Quick => Ok(json!({"tool": "quick", "points": pts, "size": self.quick_size, "mode": mode, "sampleAllLayers": self.sample_all})), + SmTool::Brush => Ok(json!({"tool": "brush", "points": pts, "size": self.brush_size, "hardness": self.hardness, "mode": mode})), + SmTool::RefineEdge => Err(json!({"points": pts, "size": self.refine_size, "mode": mode})), + SmTool::Lasso | SmTool::PolygonalLasso if pts.len() >= 3 => Ok(json!({"tool": "lasso", "points": pts, "mode": mode})), + SmTool::Object => { + let last = g.points.last().unwrap_or(first); + let (w, h) = (last[0] - first[0], last[1] - first[1]); + // A click (or a drag of a couple of pixels) picks the object under the pointer. + if w.abs().max(h.abs()) * f64::from(self.zoom.max(0.01)) < 4.0 { + Ok(json!({"tool": "object", "point": round(first), "mode": mode, "sampleAllLayers": self.sample_all})) + } else { + let r = |v: f64| (v * 10.0).round() / 10.0; + Ok(json!({"tool": "object", "rect": [r(first[0]), r(first[1]), r(w), r(h)], "mode": mode, "sampleAllLayers": self.sample_all})) + } + } + _ => return None, + }) + } + + fn end_gesture(&mut self) { + let Some(g) = self.gesture.take() else { return }; + self.trail = Some(g.clone()); + match self.finish(&g) { + Some(Ok(e)) => self.add_edit(e), + Some(Err(s)) => self.add_stroke(s), + None => self.stale(), + } + } + + /// The Polygonal Lasso's polygon closes into an edit. + fn close_polygon(&mut self) { + if let Some((add, points)) = self.poly.take() + && points.len() >= 3 + { + self.gesture = Some(Gesture { tool: SmTool::PolygonalLasso, add, points }); + self.end_gesture(); + } + } + + fn begin(&mut self, p: [f64; 2], alt: bool) { + let add = self.add != alt; + match self.tool { + SmTool::PolygonalLasso => { + let near = |q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) * f64::from(self.zoom.max(0.01)) < 6.0; + match &mut self.poly { + // Clicking the first vertex again closes the polygon. + Some((_, pts)) if pts.len() >= 3 && pts.first().is_some_and(near) => self.close_polygon(), + Some((_, pts)) => pts.push(p), + None => self.poly = Some((add, vec![p])), + } + } + t if t.edits() => { + self.gesture = Some(Gesture { tool: t, add, points: vec![p] }); + self.trail = None; + self.preview.trail = 0; + } + _ => {} + } + } + + fn extend(&mut self, p: [f64; 2]) { + let step = if self.tool.brushes() { (self.size() / 8.0).max(1.0) } else { 0.5 }; + let real_time = self.opts.real_time; + let Some(g) = self.gesture.as_mut() else { return }; + match g.tool { + // Object Selection keeps the press and the current corner. + SmTool::Object => { + g.points.truncate(1); + g.points.push(p); + } + _ => { + if g.points.last().is_some_and(|l| (l[0] - p[0]).hypot(l[1] - p[1]) < step) { + return; + } + g.points.push(p); + } + } + if real_time { + self.stale(); + } + } +} + +/// Undo the last edit, stroke or reset. +fn undo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let Some(step) = w.undo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.pop(); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.pop(); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.redo.push(back); + w.error = None; + w.stale(); +} + +fn redo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + let Some(step) = w.redo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.push(e.clone()); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.push(s.clone()); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.undo.push(back); + w.error = None; + w.stale(); +} + +/// Reset (the panel's ↺): the settings back to Photoshop's defaults and the edits and strokes +/// dropped, as one undoable step. +fn reset(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let opts = SmOpts { remember: w.opts.remember, ..SmOpts::default() }; + let prev = + Step::State { edits: std::mem::take(&mut w.edits), strokes: std::mem::take(&mut w.strokes), opts: Box::new(std::mem::replace(&mut w.opts, opts)) }; + w.push(prev); + w.error = None; + w.stale(); +} + +/// Opens the workspace on the selection (or the active layer's mask). +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { + return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); + } + let doc_index = app.session.active_index().ok_or("no document")?; + // A targeted layer mask is what is refined (Photoshop opens on it from the mask's thumbnail). + let target_mask = app.layer_mask_targeted(); + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + let canvas = st.doc.bounds(); + let revision = st.revision; + let mask_only = st.doc.selection.is_none() && layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); + let k = u32::try_from((canvas.width().max(canvas.height()) as usize).div_ceil(PROXY_SIDE).max(1)).unwrap_or(u32::MAX); + let mut opts = SmOpts::default(); + match app.session.prefs().dialogs.get(REMEMBERED) { + Some(saved) if saved.get("remember").and_then(Value::as_bool) == Some(true) => { + let _ = opts.apply(saved); + } + _ if mask_only || target_mask => opts.output = "layerMask".into(), + _ => {} + } + let mut w = SelectMaskWorkspace { + layer, + doc_index, + canvas, + revision, + target_mask, + opts, + tool: SmTool::Quick, + w_tool: SmTool::Quick, + l_tool: SmTool::Lasso, + add: true, + quick_size: 30.0, + refine_size: 30.0, + brush_size: 30.0, + hardness: 100.0, + sample_all: false, + edits: Vec::new(), + strokes: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + gesture: None, + trail: None, + poly: None, + hide_view: false, + k, + pressed: false, + texture_limit: 2048, + image_tex: None, + zoom: 0.0, + center: [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0], + hover: None, + preview: Preview::default(), + error: None, + }; + w.stale(); + app.distort.select_mask = Some(w); + Ok(()) +} + +/// The params OK runs `select.refineEdge` with. +fn commit_params(w: &SelectMaskWorkspace) -> Value { + let mut p = w.opts.refine_params(); + p.insert("sampleAllLayers".into(), json!(w.sample_all)); + if !w.edits.is_empty() { + p.insert("edits".into(), Value::Array(w.edits.clone())); + } + if !w.strokes.is_empty() { + p.insert("refineStrokes".into(), Value::Array(w.strokes.clone())); + } + if w.target_mask { + p.insert("target".into(), json!("mask")); + } + Value::Object(p) +} + +fn remember(app: &mut PhotocraftApp, opts: &SmOpts) { + let v = if opts.remember { opts.to_json() } else { json!({"remember": false}) }; + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), v)); +} + +/// OK: runs `select.refineEdge` and closes. A failure leaves the workspace open with the error in +/// its panel, so nothing painted is lost. +pub fn commit(app: &mut PhotocraftApp) -> Result { + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + w.end_gesture(); + w.close_polygon(); + let (p, opts) = (commit_params(w), w.opts.clone()); + match app.run(CMD, p) { + Ok(v) => { + remember(app, &opts); + close(app); + Ok(v) + } + Err(e) => { + if let Some(w) = app.distort.select_mask.as_mut() { + w.error = Some(e.clone()); + } + app.ui.status = e.clone(); + app.ui.status_error = true; + Err(e) + } + } +} + +/// Cancel (button, Esc): closes; the document is untouched. +pub fn cancel(app: &mut PhotocraftApp) { + close(app); +} + +fn close(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.select_mask.take() { + cancel_preview(w); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn cancel_preview(w: SelectMaskWorkspace) { + if let Some((_, _, _, job)) = w.preview.running { + job.cancel(); + } +} + +#[cfg(target_arch = "wasm32")] +fn cancel_preview(_: SelectMaskWorkspace) {} + +/// Enter: closes a Polygonal Lasso polygon, else OK. True when it closed a polygon. +pub fn enter(app: &mut PhotocraftApp) -> bool { + let Some(w) = app.distort.select_mask.as_mut() else { return false }; + if w.poly.is_some() { + w.close_polygon(); + return true; + } + let _ = commit(app); + false +} + +/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel. +pub fn escape(app: &mut PhotocraftApp) { + match app.distort.select_mask.as_mut() { + Some(w) if w.poly.is_some() => w.poly = None, + _ => cancel(app), + } +} + +/// Control channel: `select.selectAndMask {"ui": {...}}` while the workspace is open. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + let flag = |k: &str| ui.get(k).and_then(Value::as_bool) == Some(true); + if flag("cancel") { + cancel(app); + return Ok(json!({"cancelled": true})); + } + { + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let before = w.opts.clone(); + let mut o = w.opts.clone(); + o.apply(ui)?; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + let t = SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?; + w.pick(t); + } + w.opts = o; + match ui.get("mode").and_then(Value::as_str) { + None => {} + Some("add") => w.add = true, + Some("subtract") => w.add = false, + Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + } + if let Some(n) = ui.get("size").and_then(Value::as_f64) { + w.set_size(n); + } + if let Some(n) = ui.get("hardness").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.hardness = (n as f32).clamp(0.0, 100.0); + } + if let Some(b) = ui.get("sampleAllLayers").and_then(Value::as_bool) { + w.sample_all = b; + } + if let Some(b) = ui.get("hideView").and_then(Value::as_bool) { + w.hide_view = b; + } + if w.opts != before { + w.stale(); + } + if let Some(s) = ui.get("stroke") { + let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { + return Err("bad stroke points: not finite".into()); + } + let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if w.tool == SmTool::PolygonalLasso { + w.poly = Some((w.add != alt, pts)); + w.close_polygon(); + } else if let Some((first, rest)) = pts.split_first() { + w.begin(*first, alt); + for p in rest { + w.extend(*p); + } + w.end_gesture(); + } + } + let sample_all = w.sample_all; + for (key, edit) in [ + ("selectSubject", json!({"tool": "subject", "sampleAllLayers": sample_all})), + ("clearSelection", json!({"tool": "clear"})), + ("invert", json!({"tool": "invert"})), + ] { + if flag(key) { + w.add_edit(edit); + } + } + if flag("reset") { + reset(w); + } + if flag("undo") { + undo(w); + } + if flag("redo") { + redo(w); + } + } + if flag("waitPreview") { + wait_preview(app)?; + } + if flag("commit") { + return commit(app).map(|v| json!({"committed": true, "result": v})); + } + Ok(app.distort.select_mask.as_ref().map(SelectMaskWorkspace::describe).unwrap_or(Value::Null)) +} + +/// `e` (document coordinates) on the copy reduced by `k`: points, sizes and rectangles scale by +/// `1 / k`. +fn scale_edit(e: &Value, k: u32) -> Value { + let k = f64::from(k.max(1)); + let mut e = e.clone(); + let Some(o) = e.as_object_mut() else { return e }; + let scale = |v: &mut Value| { + if let Some(n) = v.as_f64() { + *v = json!(n / k); + } + }; + if let Some(pts) = o.get_mut("points").and_then(Value::as_array_mut) { + for p in pts.iter_mut().filter_map(Value::as_array_mut) { + p.iter_mut().for_each(scale); + } + } + for key in ["point", "rect"] { + if let Some(a) = o.get_mut(key).and_then(Value::as_array_mut) { + a.iter_mut().for_each(scale); + } + } + if let Some(size) = o.get("size").and_then(Value::as_f64) { + o.insert("size".into(), json!((size / k).max(1.0))); + } + e +} + +/// What a preview computation needs (moved to a worker thread). +struct Job { + doc: Arc, + revision: u64, + layer: Option, + k: u32, + target_mask: bool, + /// The committed edits and, with Real-Time Refinement, the one being drawn. + edits: Vec, + extra: Option, + /// The refinement params with `__view` and `refineStrokes`. + params: Value, + cache: Option, + proxy: Option>, + display: Option>, + texture_limit: usize, + /// [`Shown::trail`] of this preview. + trail: usize, +} + +fn job(app: &PhotocraftApp) -> Option { + let w = app.distort.select_mask.as_ref()?; + let st = app.session.active()?; + let k = w.proxy_k(); + let mut params = w.opts.refine_params(); + params.insert("sampleAllLayers".into(), json!(w.sample_all)); + params.insert("__view".into(), w.opts.view_params()); + let gesture = w.gesture.as_ref().and_then(|g| w.finish(g)); + let mut strokes = w.strokes.clone(); + let mut extra = None; + match gesture { + Some(Ok(e)) if w.opts.real_time => extra = Some(e), + Some(Err(s)) if w.opts.real_time => strokes.push(s), + _ => {} + } + if !strokes.is_empty() { + params.insert("refineStrokes".into(), Value::Array(strokes)); + } + if w.target_mask { + params.insert("target".into(), json!("mask")); + } + let proxy = w.preview.proxy.as_ref().filter(|(pk, rev, _)| *pk == k && *rev == st.revision).map(|(_, _, d)| Arc::clone(d)); + Some(Job { + doc: Arc::clone(&st.doc), + revision: st.revision, + layer: w.layer, + k, + target_mask: w.target_mask, + edits: w.edits.clone(), + extra, + params: Value::Object(params), + cache: w.preview.cache.clone(), + proxy, + display: crate::canvas::canvas_display(app, &st.doc, None).0, + texture_limit: w.texture_limit, + trail: match &w.gesture { + Some(g) if w.opts.real_time => g.points.len(), + Some(_) => 0, + None => usize::MAX, + }, + }) +} + +/// Unit edges between selected (≥ ½) and unselected pixels of `m` (`w × h`, reduced by `k`), in +/// document px; `None` past [`MAX_ANT_EDGES`]. +fn ant_edges(m: &[f32], w: usize, h: usize, k: f32) -> Option> { + let at = |x: i64, y: i64| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m.get(y as usize * w + x as usize).is_some_and(|v| *v >= 0.5); + let mut out = Vec::new(); + for y in 0..h as i64 { + for x in 0..w as i64 { + if !at(x, y) { + continue; + } + let (fx, fy) = (x as f32, y as f32); + for (n, a, b) in [ + (at(x - 1, y), [fx, fy], [fx, fy + 1.0]), + (at(x + 1, y), [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (at(x, y - 1), [fx, fy], [fx + 1.0, fy]), + (at(x, y + 1), [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + if !n { + out.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + } + } + if out.len() > MAX_ANT_EDGES { + return None; + } + } + } + Some(out) +} + +/// The preview of `job`: the edits applied (from where the cache left off), the edge refined and +/// the result shown in the view, as an image no wider than the device allows. +fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { + let k = job.k.max(1); + let proxy = job.proxy.unwrap_or_else(|| Arc::new(crate::proxy::proxy_document(&job.doc, k))); + let mut doc = (*proxy).clone(); + let layer = job.layer.filter(|id| doc.layer(*id).is_some()); + let mut params = job.params; + let start = || { + let mask = layer.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()); + if job.target_mask { mask.or_else(|| doc.selection.clone()) } else { doc.selection.clone().or(mask) } + }; + let scaled = |list: &[Value]| -> Result, String> { + let list: Vec = list.iter().map(|e| scale_edit(e, k)).collect(); + select_mask_edits::parse(&json!({ "edits": list })).map_err(|e| e.to_string()) + }; + // With no edits yet, the selection Select and Mask starts from is where the first one starts. + let mut cache = BaseCache { k, edits: Vec::new(), base: start() }; + if !job.edits.is_empty() || job.extra.is_some() { + // The selection after the edits already applied, when they still lead the list. + let (from, done) = match job.cache.filter(|c| c.k == k && job.edits.starts_with(&c.edits)) { + Some(c) => (c.base, c.edits.len()), + None => (start(), 0), + }; + let rest = scaled(job.edits.get(done..).unwrap_or_default())?; + let base = select_mask_edits::apply(&doc, layer, from, &rest); + cache = BaseCache { k, edits: job.edits.clone(), base: base.clone() }; + let base = match &job.extra { + Some(e) => select_mask_edits::apply(&doc, layer, base, &scaled(std::slice::from_ref(e))?), + None => base, + }; + // Nothing selected is an empty selection, not "use the layer mask". + doc.selection = Some(base.unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8))); + if let Some(o) = params.as_object_mut() { + o.remove("target"); + } + } + let shown = crate::select_mask_view::preview_proxy(doc, layer, ¶ms, k, cancel); + if cancel.is_some_and(photocraft_engine::jobs::JobCtx::cancelled) { + return Err("cancelled".into()); + } + let shown = shown.ok_or("the preview failed")?; + let (pw, ph) = (shown.size.width as usize, shown.size.height as usize); + let f = crate::filter_preview_ui::texture_factor(shown.size.width, shown.size.height, job.texture_limit) as usize; + let (tw, th) = (pw.div_ceil(f.max(1)), ph.div_ceil(f.max(1))); + let buf = photocraft_compose::render_reduced(&shown, u32::try_from(tw).unwrap_or(1), u32::try_from(th).unwrap_or(1)); + let image = crate::canvas::display_image(job.display.as_deref(), &buf); + let coverage = photocraft_algo::selection::mask_from_surface(shown.selection.as_ref(), shown.bounds()); + let selected = coverage.iter().filter(|v| **v >= 0.5).count(); + let mode = params.get("__view").and_then(|v| v.get("mode")).and_then(Value::as_str); + let ants = (mode == Some(Mode::MarchingAnts.id())).then(|| ant_edges(&coverage, pw, ph, k as f32)).flatten(); + let covers = [(pw as f32) * k as f32, (ph as f32) * k as f32]; + Ok((Shown { image, ants, selected, trail: job.trail, covers }, cache, proxy)) +} + +/// Takes the preview of state `generation` (made from document `revision`): the image when it is +/// still the state wanted (or a stroke is being drawn: Real-Time Refinement shows the latest one +/// it has), and the selection after the edits and the reduced copy for the next preview. +fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: Result<(Shown, BaseCache, Arc), String>) { + let pv = &mut w.preview; + match r { + Ok((shown, cache, proxy)) => { + pv.proxy = Some((cache.k, revision, proxy)); + pv.cache = Some(cache); + if generation == pv.want || w.pressed { + pv.trail = shown.trail; + if shown.trail == usize::MAX { + w.trail = None; + } + pv.shown = generation; + pv.selected = shown.selected; + pv.error = None; + pv.pending = Some(shown); + } + } + Err(e) if e == "cancelled" => {} + Err(e) => { + pv.shown = generation; + pv.error = Some(e); + } + } +} + +/// Computes the preview now, on this thread (tests and the control channel). +fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + let j = job(app).ok_or("Select and Mask is not open")?; + let revision = j.revision; + let r = compute(j, None); + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let err = r.as_ref().err().cloned(); + let generation = w.preview.want; + take_result(w, generation, revision, r); + err.map_or(Ok(()), Err) +} + +/// Starts a background preview when the shown one is out of date, and collects a finished one. +/// While a stroke is drawn with Real-Time Refinement, a running preview is let finish (cancelling +/// it on every move would never show one) and the next starts from the latest state. +#[cfg(not(target_arch = "wasm32"))] +fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some((generation, revision, rx, _)) = &w.preview.running { + match rx.try_recv() { + Ok(r) => { + let (generation, revision) = (*generation, *revision); + w.preview.running = None; + take_result(w, generation, revision, r); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + if *generation != w.preview.want + && !w.pressed + && let Some((_, _, _, job)) = &w.preview.running + { + job.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + return; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => w.preview.running = None, + } + } + let Some(w) = app.distort.select_mask.as_ref() else { return }; + let waiting = w.pressed && !w.opts.real_time; + if w.preview.running.is_some() || w.preview.shown == w.preview.want || waiting { + return; + } + let generation = w.preview.want; + let Some(j) = job(app) else { return }; + let revision = j.revision; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let ctl = photocraft_engine::jobs::JobCtx::new(); + let work = ctl.clone(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("select-mask-preview".into()).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(j, Some(&work)))).unwrap_or_else(|_| Err("the preview failed".into())); + let _ = tx.send(r); + repaint.request_repaint(); + }); + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match spawned { + Ok(_) => w.preview.running = Some((generation, revision, rx, ctl)), + Err(e) => w.preview.error = Some(format!("could not start the preview: {e}")), + } +} + +/// The web build has no threads: the preview is computed when the pointer comes up. +#[cfg(target_arch = "wasm32")] +fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context) { + let due = app.distort.select_mask.as_ref().is_some_and(|w| w.preview.shown != w.preview.want && !w.pressed); + if due { + let _ = wait_preview(app); + } +} + +/// Pointer in document coordinates (from the view or the control channel). +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match ev { + ToolEvent::Down { x, y, .. } if w.tool == SmTool::Zoom => { + let f = if mods.alt { 0.5 } else { 2.0 }; + w.zoom = (w.zoom * f).clamp(0.01, 32.0); + w.center = [x, y]; + } + ToolEvent::Down { x, y, .. } => w.begin([x, y], mods.alt), + ToolEvent::Move { x, y, .. } => { + w.hover = Some([x, y]); + w.extend([x, y]); + } + ToolEvent::Up { x, y } => { + w.extend([x, y]); + w.end_gesture(); + } + } +} + +/// The workspace's keys (unless a text field has the keyboard): tools, brush size, views, undo. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + let Some(w) = app.distort.select_mask.as_mut() else { return }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, Key::Z)) { + redo(w); + return; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::Z)) { + undo(w); + return; + } + if egui::Popup::is_any_open(ctx) { + w.hide_view = false; + return; + } + let pressed = |k: Key, m: Modifiers| ctx.input_mut(|i| i.consume_key(m, k)); + if pressed(Key::W, Modifiers::SHIFT) { + w.pick(if w.w_tool == SmTool::Quick { SmTool::Object } else { SmTool::Quick }); + } + if pressed(Key::L, Modifiers::SHIFT) { + w.pick(if w.l_tool == SmTool::Lasso { SmTool::PolygonalLasso } else { SmTool::Lasso }); + } + for (k, t) in + [(Key::W, w.w_tool), (Key::R, SmTool::RefineEdge), (Key::B, SmTool::Brush), (Key::L, w.l_tool), (Key::H, SmTool::Hand), (Key::Z, SmTool::Zoom)] + { + if pressed(k, Modifiers::NONE) { + w.pick(t); + } + } + if pressed(Key::OpenBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 0.9).min(s - 1.0)); + } + if pressed(Key::CloseBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 1.1).max(s + 1.0)); + } + if (pressed(Key::Backspace, Modifiers::NONE) || pressed(Key::Delete, Modifiers::NONE)) + && let Some((_, pts)) = w.poly.as_mut() + { + pts.pop(); + if pts.is_empty() { + w.poly = None; + } + } + // The views: their letters, F cycles, J Show Edge, P Show Original; holding X shows the image. + let before = w.opts.clone(); + if pressed(Key::F, Modifiers::NONE) { + w.opts.view = w.opts.view.next(); + } + for m in Mode::ALL { + if pressed(m.key(), Modifiers::NONE) { + w.opts.view = m; + } + } + if pressed(Key::J, Modifiers::NONE) { + w.opts.show_edge = !w.opts.show_edge; + } + if pressed(Key::P, Modifiers::NONE) { + w.opts.show_original = !w.opts.show_original; + } + if w.opts != before { + w.stale(); + } + w.hide_view = ctx.input(|i| i.key_down(Key::X) && !i.modifiers.command && !i.modifiers.alt && !i.modifiers.ctrl); +} + +/// A view: screen px per document px and the document point at the centre of `area`. +#[derive(Clone, Copy)] +struct View { + area: ERect, + zoom: f32, + center: [f64; 2], +} + +impl View { + fn to_screen(self, p: [f64; 2]) -> Pos2 { + let c = self.area.center(); + pos2(c.x + ((p[0] - self.center[0]) as f32) * self.zoom, c.y + ((p[1] - self.center[1]) as f32) * self.zoom) + } + fn to_doc(self, s: Pos2) -> [f64; 2] { + let c = self.area.center(); + [self.center[0] + f64::from((s.x - c.x) / self.zoom), self.center[1] + f64::from((s.y - c.y) / self.zoom)] + } + fn rect(self, r: Rect) -> ERect { + ERect::from_min_max(self.to_screen([f64::from(r.x0), f64::from(r.y0)]), self.to_screen([f64::from(r.x1), f64::from(r.y1)])) + } +} + +/// Upload what changed: the document (for X and until the first preview) and a finished preview. +fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { + let need_image = app.distort.select_mask.as_ref().is_some_and(|w| w.image_tex.is_none()); + if need_image { + let img = app.session.active().map(|st| { + let (cw, ch) = (st.doc.size.width, st.doc.size.height); + let f = crate::filter_preview_ui::texture_factor(cw, ch, PROXY_SIDE); + let buf = photocraft_compose::render_reduced(&st.doc, cw.div_ceil(f.max(1)), ch.div_ceil(f.max(1))); + let (display, _) = crate::canvas::canvas_display(app, &st.doc, None); + crate::canvas::display_image(display.as_deref(), &buf) + }); + if let (Some(w), Some(img)) = (app.distort.select_mask.as_mut(), img) { + w.image_tex = Some(ctx.load_texture("select-mask-image", img, egui::TextureOptions::LINEAR)); + } + } + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some(s) = w.preview.pending.take() { + match &mut w.preview.tex { + Some(t) => t.set(s.image, egui::TextureOptions::LINEAR), + None => w.preview.tex = Some(ctx.load_texture("select-mask-preview", s.image, egui::TextureOptions::LINEAR)), + } + w.preview.ants = s.ants; + w.preview.covers = s.covers; + } +} + +/// A section of the Properties panel: a bold title that folds it, open by default. +fn section(ui: &mut egui::Ui, id: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(egui::RichText::new(title).strong()).id_salt(id).default_open(true).show(ui, body); +} + +/// The options bar of the current tool. +fn options_bar(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace) -> Option { + let mut edit = None; + crate::icons::button(ui, w.tool.icon(), 26.0, false, &w.tool.tip()); + ui.add_space(6.0); + if w.tool.edits() { + if crate::icons::button(ui, "plus", 26.0, w.add, tl!("Add")).clicked() { + w.add = true; + } + if crate::icons::button(ui, "minus", 26.0, !w.add, tl!("Subtract")).clicked() { + w.add = false; + } + ui.add_space(8.0); + } + if w.tool.brushes() { + ui.label(tl!("Size:")); + let mut size = w.size() as f32; + if widgets::value_field(ui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + w.set_size(f64::from(size)); + } + ui.add_space(6.0); + } + if w.tool == SmTool::Brush { + ui.label(tl!("Hardness:")); + widgets::value_field(ui, &mut w.hardness, 0.0..=100.0, "%", 56.0); + ui.add_space(6.0); + } + if matches!(w.tool, SmTool::Quick | SmTool::RefineEdge | SmTool::Object) && widgets::checkbox(ui, &mut w.sample_all, tl!("Sample All Layers")).changed() { + w.stale(); + } + if w.tool.edits() { + ui.add_space(8.0); + if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { + edit = Some(json!({"tool": "subject", "sampleAllLayers": w.sample_all})); + } + // Refine Hair needs hair detection PhotoCraft doesn't have yet (#2671): greyed, saying so. + ui.add_enabled_ui(false, |ui| widgets::secondary_button(ui, tl!("Refine Hair"), 0.0)) + .inner + .on_disabled_hover_text(tl!("Refine Hair needs hair detection, which isn't available yet")); + } + edit +} + +/// The Properties panel. Returns the tool edit its buttons made (Clear Selection, Invert). +fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Option { + let mut edit = None; + ui.spacing_mut().item_spacing.y = 6.0; + widgets::checkbox(ui, &mut w.opts.remember, tl!("Remember Settings")); + widgets::hairline(ui); + section(ui, "sm-view", tl!("View Mode"), |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("View")).color(t.text_dim)); + let labels: Vec = Mode::ALL.iter().map(|m| format!("{} ({:?})", m.label(), m.key())).collect(); + let options: Vec<(Mode, &str)> = Mode::ALL.iter().zip(&labels).map(|(m, l)| (*m, l.as_str())).collect(); + // The labels carry the view's letter, already translated. + widgets::dropdown_literal(ui, "sm-view-mode", &mut w.opts.view, &options, 112.0); + }); + // Beside the view list, as Photoshop's; long labels are cut rather than widening the + // panel past its edge (every row below lines up on that edge). + let room = ui.available_width(); + ui.vertical(|ui| { + ui.set_max_width(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + widgets::checkbox(ui, &mut w.opts.show_edge, &format!("{} (J)", tl!("Show Edge"))); + widgets::checkbox(ui, &mut w.opts.show_original, &format!("{} (P)", tl!("Show Original"))); + widgets::checkbox(ui, &mut w.opts.real_time, tl!("Real-Time Refinement")); + widgets::checkbox(ui, &mut w.opts.high_quality, tl!("High Quality Preview")); + }); + }); + let i = mode_index(w.opts.view); + if w.opts.view.default_opacity().is_some() { + let label = if w.opts.view == Mode::OnionSkin { tl!("Transparency") } else { tl!("Opacity") }; + if let Some(o) = w.opts.opacity.get_mut(i) { + widgets::slider_row(ui, label, o, 0.0..=100.0, "%", None); + } + } + if w.opts.view == Mode::Overlay { + ui.horizontal(|ui| { + ui.label(tl!("Color")); + widgets::color_edit_button_srgb(ui, &mut w.opts.overlay_color); + ui.add_space(8.0); + ui.label(tl!("Indicates")); + widgets::dropdown(ui, "sm-indicates", &mut w.opts.indicates_selected, &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], 120.0); + }); + } + }); + section(ui, "sm-refine-mode", tl!("Refine Mode"), |ui| { + ui.horizontal(|ui| { + let _ = ui.selectable_label(true, tl!("Color Aware")); + // Object Aware needs a learned segmentation model (#2788). + ui.add_enabled_ui(false, |ui| ui.selectable_label(false, tl!("Object Aware"))) + .inner + .on_disabled_hover_text(tl!("Object Aware needs a segmentation model, which isn't available yet")); + }); + }); + section(ui, "sm-edge", tl!("Edge Detection"), |ui| { + widgets::slider_row(ui, tl!("Radius"), &mut w.opts.radius, 0.0..=250.0, " px", None); + widgets::checkbox(ui, &mut w.opts.smart_radius, tl!("Smart Radius")); + }); + section(ui, "sm-global", tl!("Global Refinements"), |ui| { + widgets::slider_row(ui, tl!("Smooth"), &mut w.opts.smooth, 0.0..=100.0, "", None); + widgets::slider_row(ui, tl!("Feather"), &mut w.opts.feather, 0.0..=1000.0, " px", None); + widgets::slider_row(ui, tl!("Contrast"), &mut w.opts.contrast, 0.0..=100.0, "%", None); + widgets::slider_row(ui, tl!("Shift Edge"), &mut w.opts.shift_edge, -100.0..=100.0, "%", None); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Clear Selection"), 0.0).clicked() { + edit = Some(json!({"tool": "clear"})); + } + if widgets::secondary_button(ui, tl!("Invert"), 0.0).clicked() { + edit = Some(json!({"tool": "invert"})); + } + }); + }); + section(ui, "sm-output", tl!("Output Settings"), |ui| { + widgets::checkbox(ui, &mut w.opts.decontaminate, tl!("Decontaminate Colors")); + ui.add_enabled_ui(w.opts.decontaminate, |ui| widgets::slider_row(ui, tl!("Amount"), &mut w.opts.amount, 0.0..=100.0, "%", None)); + w.opts.follow_decontaminate(); + ui.horizontal(|ui| { + ui.label(tl!("Output To")); + let labels = + [tl!("Selection"), tl!("Layer Mask"), tl!("New Layer"), tl!("New Layer With Mask"), tl!("New Document"), tl!("New Document With Mask")]; + // Decontaminating makes new pixels: only the new layer and document targets apply. + let options: Vec<(String, &str)> = OUTPUTS + .iter() + .zip(labels) + .filter(|(o, _)| !w.opts.decontaminate || !matches!(**o, "selection" | "layerMask")) + .map(|(o, l)| (o.to_string(), l)) + .collect(); + widgets::dropdown(ui, "sm-output-to", &mut w.opts.output, &options, 170.0); + }); + }); + if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { + ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); + } + edit +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + // The document changed under the workspace: new textures, a new preview. + let rev = app.session.active().map(|s| s.revision); + if let Some(w) = app.distort.select_mask.as_mut() + && Some(w.revision) != rev + { + w.revision = rev.unwrap_or(w.revision); + w.image_tex = None; + w.preview.cache = None; + w.stale(); + } + upload(app, ctx); + let title = app.distort.select_mask.as_ref().map(|w| crate::canvas::tab_title_at(app, w.doc_index, if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 })); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut action: Option<&str> = None; + let mut events: Vec = Vec::new(); + let mut mods = egui::Modifiers::NONE; + let mut edit: Option = None; + let mut do_reset = false; + egui::Area::new(egui::Id::new("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + let before = (w.opts.clone(), w.sample_all); + w.texture_limit = ctx.input(|i| i.max_texture_side); + let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); + let painter = ui.painter().clone(); + painter.rect_filled(full, 0.0, t.chrome); + // The options bar across the top, as Photoshop's. + let bar = ERect::from_min_size(full.min, vec2(full.width(), BAR_H)); + painter.rect_filled(bar, 0.0, t.dock); + painter.line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.separator)); + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(8.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + if let Some(e) = options_bar(&mut bui, w) { + edit = Some(e); + } + let body = ERect::from_min_max(pos2(full.left(), bar.bottom()), full.max); + // The tools on the left: Lasso and Polygonal Lasso share a slot (right-click to pick). + let left = ERect::from_min_size(body.min, vec2(LEFT_W, body.height())); + painter.rect_filled(left, 0.0, t.dock); + painter.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0)))); + strip.spacing_mut().item_spacing.y = 4.0; + for slot in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, w.l_tool, SmTool::Hand, SmTool::Zoom] { + let r = crate::icons::button(&mut strip, slot.icon(), 34.0, w.tool == slot, &slot.tip()); + if r.clicked() { + w.pick(slot); + } + if matches!(slot, SmTool::Lasso | SmTool::PolygonalLasso) { + // The slot's flyout triangle, as the main toolbar's. + let c = r.rect.right_bottom() - vec2(3.0, 3.0); + painter.add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.icon, Stroke::NONE)); + r.context_menu(|ui| { + for l in [SmTool::Lasso, SmTool::PolygonalLasso] { + if ui.selectable_label(w.tool == l, l.tip()).clicked() { + w.pick(l); + ui.close(); + } + } + }); + } + } + // The Properties panel on the right. + let right = ERect::from_min_size(pos2(body.right() - PANEL_W, body.top()), vec2(PANEL_W, body.height())); + painter.rect_filled(right, 0.0, t.dock); + painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); + let head = ERect::from_min_size(right.min, vec2(PANEL_W, TAB_H)); + painter.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); + painter.text(head.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, tl!("Properties"), FontId::proportional(12.0), t.text); + let mut props = ui.new_child( + egui::UiBuilder::new().max_rect(ERect::from_min_max(head.left_bottom() + vec2(14.0, 10.0), pos2(right.right() - 14.0, right.bottom() - 56.0))), + ); + egui::ScrollArea::vertical().id_salt("sm-props").show(&mut props, |ui| { + if let Some(e) = properties(ui, w, &t) { + edit = Some(e); + } + }); + // Reset at the left, OK and Cancel against the right edge. + let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 46.0), vec2(PANEL_W - 20.0, 32.0)); + painter.line_segment([pos2(right.left(), foot.top() - 6.0), pos2(right.right(), foot.top() - 6.0)], Stroke::new(1.0, t.separator)); + let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); + if widgets::secondary_button(&mut fb, tl!("Cancel"), 70.0).clicked() { + action = Some("cancel"); + } + if widgets::primary_button(&mut fb, tl!("OK"), 64.0).clicked() { + action = Some("ok"); + } + fb.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 24.0, false, tl!("Reset")).clicked() { + do_reset = true; + } + }); + // The document under its tab. + let doc_area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(right.left(), body.bottom())); + let tab = ERect::from_min_size(doc_area.min, vec2(doc_area.width(), TAB_H)); + painter.rect_filled(tab, 0.0, t.dock); + painter.line_segment([tab.left_bottom(), tab.right_bottom()], Stroke::new(1.0, t.separator)); + let title = title.clone().unwrap_or_default(); + let galley = painter.layout_no_wrap(title, FontId::proportional(12.0), t.text); + let tab_w = (galley.size().x + 28.0).min(tab.width()); + let active_tab = ERect::from_min_size(tab.min, vec2(tab_w, TAB_H)); + painter.rect_filled(active_tab, 0.0, t.canvas); + painter.galley(pos2(active_tab.left() + 14.0, active_tab.center().y - galley.size().y / 2.0), galley, t.text); + let area = ERect::from_min_max(pos2(doc_area.left(), tab.bottom()), doc_area.max); + painter.rect_filled(area, 0.0, t.canvas); + let (cw, chh) = (w.canvas.width() as f32, w.canvas.height() as f32); + if w.zoom <= 0.0 { + w.zoom = ((area.width() - 40.0) / cw.max(1.0)).min((area.height() - 40.0) / chh.max(1.0)).clamp(0.01, 4.0); + } + let resp = ui.interact(area, egui::Id::new("select-mask-view"), Sense::click_and_drag()); + // Over the image the keys are the workspace's: a panel button clicked last keeps the focus + // otherwise, and Space (held to pan) would press it again. A number being typed keeps it. + if resp.hovered() + && !ctx.text_edit_focused() + && let Some(focus) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(focus)); + } + if resp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 + && let Some(hp) = resp.hover_pos() + { + let v = View { area, zoom: w.zoom, center: w.center }; + let at = v.to_doc(hp); + w.zoom = (w.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + let after = View { area, zoom: w.zoom, center: w.center }.to_doc(hp); + w.center = [w.center[0] + at[0] - after[0], w.center[1] + at[1] - after[1]]; + } + } + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + widgets::checker(&clip, img.intersect(area), 8.0); + let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); + match (preview, &w.image_tex) { + (Some(tex), _) => { + // The reduced copy's last row and column may reach past the canvas. + let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); + } + (None, Some(tex)) => { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + (None, None) => {} + } + // Marching Ants: the refined selection's edge, black dashes on white. + if !w.hide_view + && w.opts.view == Mode::MarchingAnts + && let Some(edges) = &w.preview.ants + { + let phase = (ui.input(|i| i.time) * 8.0) as usize; + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + } + if w.preview.shown != w.preview.want && !w.pressed { + let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + // The stroke being drawn: the part the preview doesn't show yet (all of it without + // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. + let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); + if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { + let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); + match g.tool { + SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { + let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); + let r = w.size() as f32 * 0.5 * v.zoom; + for p in tail { + clip.circle_filled(*p, r, ink); + } + if tail.len() > 1 { + clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); + } + } + SmTool::Lasso => { + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + SmTool::Object => { + if let (Some(a), Some(b)) = (line.first(), line.last()) { + clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + } + _ => {} + } + } + if let Some((_, pts)) = &w.poly { + let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + if let Some(hp) = resp.hover_pos() { + line.push(hp); + } + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + // Space, the middle button or the Hand pan. The Polygonal Lasso takes clicks: each adds a + // vertex, a double-click closes. + let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == SmTool::Hand; + // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle + // (with + or −), the zoom glass, else a crosshair. + if let Some(hp) = resp.hover_pos() { + w.hover = Some(v.to_doc(hp)); + } + let alt = ui.input(|i| i.modifiers.alt); + if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if let Some(hp) = resp.hover_pos() { + if pan { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } else if w.tool.brushes() { + let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + } else { + ctx.set_cursor_icon(match w.tool { + SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, + SmTool::Zoom => egui::CursorIcon::ZoomIn, + _ => egui::CursorIcon::Crosshair, + }); + } + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if w.tool == SmTool::PolygonalLasso && !pan { + mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + w.close_polygon(); + } else if resp.clicked() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + } + if (w.opts.clone(), w.sample_all) != before { + w.stale(); + } + }); + for ev in events { + pointer(app, ev, mods); + } + if let Some(w) = app.distort.select_mask.as_mut() { + if let Some(e) = edit { + w.add_edit(e); + } + if do_reset { + reset(w); + } + } + drive_preview(app, ctx); + match action { + Some("ok") => { + let _ = commit(app); + } + Some("cancel") => cancel(app), + _ => {} + } +} + +#[cfg(test)] +#[path = "select_mask_ui_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs new file mode 100644 index 0000000000..db4b3cc913 --- /dev/null +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -0,0 +1,544 @@ +use super::*; +use egui::{Key, Modifiers}; +use egui_kittest::kittest::{NodeT, Queryable}; + +/// A 160 × 100 document: warm on the left (x < 80), cool on the right, with the top-left +/// 20 × 20 selected. +fn app_with_selection() -> PhotocraftApp { + app_sized(160, 100) +} + +fn app_sized(w: i32, h: i32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap(); + app.session + .edit("paint", |doc, _| { + let s = doc.layers[0].surface_mut().unwrap(); + s.fill_rect(Rect::new(0, 0, w / 2, h), &[0.85, 0.55, 0.25, 1.0]); + s.fill_rect(Rect::new(w / 2, 0, w, h), &[0.15, 0.35, 0.75, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + app.sync_views(); + app +} + +fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace { + app.distort.select_mask.as_ref().unwrap() +} + +fn ui(app: &mut PhotocraftApp, v: Value) -> Value { + control(app, &v).unwrap() +} + +fn selected(app: &mut PhotocraftApp) -> u64 { + ui(app, json!({"waitPreview": true}))["selectedProxyPixels"].as_u64().unwrap() +} + +fn cov(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) +} + +/// Photoshop opens on Quick Selection adding, the Onion Skin view, Real-Time Refinement on, and +/// output to the selection. +#[test] +fn opens_like_photoshop() { + let mut app = app_with_selection(); + crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})).unwrap(); + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["mode"].as_str(), d["size"].as_f64()), (Some("quick"), Some("add"), Some(30.0))); + let s = &d["settings"]; + assert_eq!((s["view"].as_str(), s["output"].as_str()), (Some("onionSkin"), Some("selection"))); + assert_eq!((s["realTimeRefinement"].as_bool(), s["highQualityPreview"].as_bool(), s["remember"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((s["opacity"]["onionSkin"].as_f64(), s["opacity"]["onBlack"].as_f64()), (Some(50.0), Some(100.0))); + assert_eq!(selected(&mut app), 400, "the 20 × 20 selection"); + assert!(ws(&app).describe()["previewReady"].as_bool().unwrap()); +} + +#[test] +fn opening_needs_a_selection_or_a_layer_mask() { + let mut app = app_with_selection(); + app.session.execute("select.deselect", json!({})).unwrap(); + assert!(open(&mut app).is_err()); + assert!(app.distort.select_mask.is_none()); + // `ui` addresses an open workspace only. + let e = crate::distort_ui::menu(&mut app, &egui::Context::default(), "select.selectAndMask", &json!({"ui": {}})).unwrap(); + assert!(e.is_err()); +} + +/// Every tool's stroke is an edit (the Refine Edge Brush's a stroke), each one undoable. +#[test] +fn tools_make_edits_and_undo_and_redo_them() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = selected(&mut app); + // Quick Selection across the cool half grows the selection over it. + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let quick = selected(&mut app); + assert!(quick > before + 3000, "{before} → {quick}"); + // Brush, Lasso and Polygonal Lasso subtract with Alt. + ui(&mut app, json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}})); + let brushed = selected(&mut app); + assert!(brushed < quick, "{quick} → {brushed}"); + ui(&mut app, json!({"tool": "lasso", "mode": "subtract", "stroke": {"points": [[140.0, 0.0], [160.0, 0.0], [160.0, 100.0], [140.0, 100.0]]}})); + ui(&mut app, json!({"tool": "polygonalLasso", "mode": "add", "stroke": {"points": [[30.0, 30.0], [50.0, 30.0], [50.0, 50.0]]}})); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[80.0, 20.0], [80.0, 80.0]]}})); + let d = ws(&app).describe(); + assert_eq!((d["edits"].as_u64(), d["strokes"].as_u64(), d["undo"].as_u64()), (Some(4), Some(1), Some(5))); + let e = &ws(&app).edits; + assert_eq!( + (e[1]["tool"].as_str(), e[1]["mode"].as_str(), e[1]["hardness"].as_f64(), e[1]["size"].as_f64()), + (Some("brush"), Some("subtract"), Some(50.0), Some(10.0)) + ); + assert_eq!((e[2]["tool"].as_str(), e[2]["mode"].as_str()), (Some("lasso"), Some("subtract"))); + assert_eq!((e[3]["tool"].as_str(), e[3]["points"].as_array().map(Vec::len)), (Some("lasso"), Some(3))); + // Undo everything but the Quick Selection stroke, then redo the Brush's. + for _ in 0..4 { + ui(&mut app, json!({"undo": true})); + } + assert_eq!(selected(&mut app), quick); + ui(&mut app, json!({"redo": true})); + assert_eq!(selected(&mut app), brushed); + // A new edit drops what could be redone. + ui(&mut app, json!({"invert": true})); + assert_eq!(ws(&app).describe()["redo"].as_u64(), Some(0)); +} + +/// Object Selection: a click picks the object under it, a drag the object in the rectangle. +#[test] +fn object_selection_clicks_and_drags() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "object", "stroke": {"points": [[120.0, 50.0], [121.0, 50.0]]}})); + ui(&mut app, json!({"stroke": {"points": [[90.0, 10.0], [150.0, 90.0]]}})); + let e = &ws(&app).edits; + assert_eq!(e[0]["point"], json!([120.0, 50.0])); + assert_eq!(e[1]["rect"], json!([90.0, 10.0, 60.0, 80.0])); +} + +fn ws_mut(app: &mut PhotocraftApp) -> &mut SelectMaskWorkspace { + app.distort.select_mask.as_mut().unwrap() +} + +/// Select Subject, Clear Selection and Invert are edits too; Reset drops everything at once, +/// undoably. +#[test] +fn buttons_and_reset() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true})); + assert_eq!(selected(&mut app), 0); + ui(&mut app, json!({"invert": true})); + assert_eq!(selected(&mut app), 160 * 100); + ui(&mut app, json!({"selectSubject": true})); + assert_eq!(ws(&app).edits.last().and_then(|e| e["tool"].as_str()), Some("subject")); + ui(&mut app, json!({"radius": 12, "view": "blackWhite", "remember": true})); + let d = ui(&mut app, json!({"reset": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64(), d["settings"]["view"].as_str()), (Some(0), Some(0.0), Some("onionSkin"))); + assert_eq!(d["settings"]["remember"].as_bool(), Some(true), "Reset keeps Remember Settings"); + assert_eq!(selected(&mut app), 400); + let d = ui(&mut app, json!({"undo": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64()), (Some(3), Some(12.0))); +} + +/// OK runs `select.refineEdge` with the edits, strokes and settings: one history step, the same +/// result as the command with those params, and the workspace closes. +#[test] +fn ok_is_one_step_and_matches_the_command() { + let mut app = app_with_selection(); + let mut direct = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"radius": 2, "stroke": {"points": [[120.0, 50.0]]}})); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0], [60.0, 60.0]]}})); + let params = commit_params(ws(&app)); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"commit": true})); + assert_eq!(r["committed"], json!(true)); + assert!(app.distort.select_mask.is_none(), "OK closes"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Select and Mask")); + direct.session.execute(CMD, params).unwrap(); + for (x, y) in [(5, 5), (120, 50), (50, 60), (50, 80), (79, 50)] { + assert_eq!(cov(&app, x, y), cov(&direct, x, y), "({x}, {y})"); + } + assert!(cov(&app, 120, 50) > 0.5 && cov(&app, 50, 60) > 0.5 && cov(&app, 50, 80) < 0.5); +} + +/// Cancel leaves the document as it was. +#[test] +fn cancel_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"invert": true, "radius": 10})); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"cancel": true})); + assert_eq!(r["cancelled"], json!(true)); + assert!(app.distort.select_mask.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert!(cov(&app, 5, 5) > 0.99 && cov(&app, 100, 50) < 0.01); +} + +/// Remember Settings keeps the settings for the next time; without it the workspace opens on +/// Photoshop's defaults. +#[test] +fn remember_settings() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"remember": true, "radius": 7, "view": "overlay", "overlayColor": [0, 0, 255], "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str(), s["overlayColor"].clone()), (Some(7.0), Some("overlay"), json!([0, 0, 255]))); + ui(&mut app, json!({"remember": false, "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str()), (Some(0.0), Some("onionSkin"))); +} + +/// Decontaminate Colors makes new pixels, so it outputs to a new layer (as Photoshop does). +#[test] +fn decontaminate_outputs_to_a_new_layer() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let d = ui(&mut app, json!({"decontaminate": true})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); + let d = ui(&mut app, json!({"output": "newDocument"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newDocument")); + let d = ui(&mut app, json!({"output": "selection"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); +} + +/// A layer mask without a selection (or the targeted mask) is refined and output to the mask. +#[test] +fn a_layer_mask_opens_on_the_mask() { + let mut app = app_with_selection(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + // Revealing the selection uses it up (#8381), leaving the mask alone. + app.session.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["settings"]["output"].as_str(), Some("layerMask")); + assert_eq!(selected(&mut app), 400, "the mask is what is shown"); +} + +/// Bad control params are errors, never a panic, and change nothing. +#[test] +fn hostile_control_params_are_errors() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + for bad in [ + json!({"tool": "wand"}), + json!({"mode": "intersect"}), + json!({"view": "sideways"}), + json!({"output": "printer"}), + json!({"indicates": "both"}), + json!({"stroke": {"points": "x"}}), + json!({"stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + } + for odd in [json!({"size": -5}), json!({"size": 1e12}), json!({"hardness": 500}), json!({"radius": 1e9, "opacity": -3}), json!({"stroke": {"points": []}})] + { + control(&mut app, &odd).unwrap(); + } + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["edits"].as_u64()), (Some("quick"), Some(0))); + assert_eq!((d["size"].as_f64(), d["hardness"].as_f64(), d["settings"]["radius"].as_f64()), (Some(5000.0), Some(100.0), Some(250.0))); + assert!(control(&mut app, &json!({"waitPreview": true})).is_ok(), "the clamped settings still preview"); +} + +/// High Quality Preview runs on the document at full size; otherwise on a copy at most 1600 px. +#[test] +fn high_quality_preview_is_full_size() { + let mut app = app_sized(3300, 60); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["proxyScale"].as_u64(), Some(3)); + let reduced = selected(&mut app); + assert!((40..=60).contains(&reduced), "{reduced} reduced px of the 20 × 20 selection"); + assert_eq!(ui(&mut app, json!({"highQualityPreview": true}))["proxyScale"].as_u64(), Some(1)); + assert_eq!(selected(&mut app), 400); +} + +/// Edits scale with the reduced copy: points, rectangles and sizes by 1 / k. +#[test] +fn edits_scale_to_the_reduced_copy() { + let e = json!({"tool": "quick", "points": [[30.0, 60.0]], "size": 30.0, "rect": [3, 6, 9, 12], "point": [15, 3]}); + assert_eq!(scale_edit(&e, 3), json!({"tool": "quick", "points": [[10.0, 20.0]], "size": 10.0, "rect": [1.0, 2.0, 3.0, 4.0], "point": [5.0, 1.0]})); + assert_eq!(scale_edit(&json!({"size": 2.0}), 4)["size"], json!(1.0), "never below a pixel"); + assert_eq!(scale_edit(&json!("x"), 2), json!("x")); +} + +/// With Real-Time Refinement the stroke being drawn is in the preview before it is released. +#[test] +fn real_time_refinement_previews_the_stroke_being_drawn() { + for real_time in [true, false] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"realTimeRefinement": real_time})); + let before = selected(&mut app); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let during = selected(&mut app); + assert_eq!(during > before, real_time, "real time {real_time}: {before} → {during}"); + // The preview knows how much of the stroke it shows: the rest is drawn over it. + assert_eq!(ws(&app).preview.trail, if real_time { 2 } else { 0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + assert!(ws(&app).trail.is_some(), "the released stroke stays drawn until its preview comes"); + assert!(selected(&mut app) > before); + assert_eq!(ws(&app).edits.len(), 1); + assert!(ws(&app).trail.is_none() && ws(&app).preview.trail == usize::MAX, "then the preview shows all of it"); + } +} + +/// The preview keeps the selection after the edits it applied and starts the next from there: +/// after undo it starts over. +#[test] +fn the_preview_reuses_the_edits_it_applied() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0]]}})); + selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0]]}})); + let both = selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(2)); + ui(&mut app, json!({"undo": true})); + let one = selected(&mut app); + assert!(one < both); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); +} + +/// Sends `key` with `modifiers` through the workspace's key handling. +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear(); +} + +/// Photoshop's keys: W, R, B, L, H, Z pick the tools (Shift+W and Shift+L cycle their slots), +/// [ and ] size the brush, the view letters, F, J and P the view, Ctrl+Z undoes. +#[test] +fn keys_like_photoshop() { + let mut app = app_with_selection(); + let ctx = egui::Context::default(); + open(&mut app).unwrap(); + let tool = |app: &PhotocraftApp| ws(app).tool; + for (key, mods, want) in [ + (Key::R, Modifiers::NONE, SmTool::RefineEdge), + (Key::B, Modifiers::NONE, SmTool::Brush), + (Key::W, Modifiers::NONE, SmTool::Quick), + (Key::W, Modifiers::SHIFT, SmTool::Object), + (Key::H, Modifiers::NONE, SmTool::Hand), + (Key::W, Modifiers::NONE, SmTool::Object), + (Key::L, Modifiers::NONE, SmTool::Lasso), + (Key::L, Modifiers::SHIFT, SmTool::PolygonalLasso), + (Key::Z, Modifiers::NONE, SmTool::Zoom), + (Key::L, Modifiers::NONE, SmTool::PolygonalLasso), + (Key::B, Modifiers::NONE, SmTool::Brush), + ] { + press(&mut app, &ctx, key, mods); + assert_eq!(tool(&app), want, "{mods:?}+{key:?}"); + } + press(&mut app, &ctx, Key::CloseBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 33.0); + press(&mut app, &ctx, Key::OpenBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 30.0); + let view = |app: &PhotocraftApp| ws(app).opts.view; + for (key, want) in [ + (Key::K, Mode::BlackWhite), + (Key::A, Mode::OnBlack), + (Key::T, Mode::OnWhite), + (Key::V, Mode::Overlay), + (Key::Y, Mode::OnLayers), + (Key::M, Mode::MarchingAnts), + (Key::F, Mode::Overlay), + (Key::O, Mode::OnionSkin), + ] { + press(&mut app, &ctx, key, Modifiers::NONE); + assert_eq!(view(&app), want, "{key:?}"); + } + press(&mut app, &ctx, Key::J, Modifiers::NONE); + press(&mut app, &ctx, Key::P, Modifiers::NONE); + assert!(ws(&app).opts.show_edge && ws(&app).opts.show_original); + ui(&mut app, json!({"invert": true})); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND); + assert_eq!(ws(&app).edits.len(), 0); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(ws(&app).edits.len(), 1); +} + +/// The Polygonal Lasso takes clicks: Enter closes the polygon (not OK), Esc drops it (not Cancel), +/// clicking the first vertex closes it too. +#[test] +fn polygonal_lasso_enter_and_escape() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "polygonalLasso"})); + let click = |app: &mut PhotocraftApp, x: f64, y: f64| crate::distort_ui::pointer(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(30.0, 30.0), (60.0, 30.0), (60.0, 60.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).describe()["polygon"].as_u64(), Some(3)); + assert!(enter(&mut app), "Enter closes the polygon"); + assert!(app.distort.select_mask.is_some(), "and leaves the workspace open"); + assert_eq!(ws(&app).edits.len(), 1); + click(&mut app, 30.0, 30.0); + click(&mut app, 60.0, 30.0); + escape(&mut app); + assert!(app.distort.select_mask.is_some() && ws(&app).poly.is_none(), "Esc drops the polygon only"); + for (x, y) in [(70.0, 70.0), (90.0, 70.0), (90.0, 90.0), (71.0, 71.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).edits.len(), 2, "clicking the first vertex closes it"); + escape(&mut app); + assert!(app.distort.select_mask.is_none(), "Esc with no polygon cancels"); +} + +/// The workspace draws: tools on the left, the options bar, the Properties panel with Photoshop's +/// sections and buttons; clicking a tool picks it, OK commits. +#[test] +fn renders_photoshops_layout() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for label in [ + "Remember Settings", + "View Mode", + "Real-Time Refinement", + "High Quality Preview", + "Refine Mode", + "Color Aware", + "Edge Detection", + "Smart Radius", + "Global Refinements", + "Clear Selection", + "Invert", + "Output Settings", + "Decontaminate Colors", + "Select Subject", + "Refine Hair", + "Sample All Layers", + "OK", + "Cancel", + ] { + assert!(h.query_by_label_contains(label).is_some(), "{label} is shown"); + } + assert!(h.get_by_label("Refine Hair").accesskit_node().is_disabled(), "Refine Hair is greyed out"); + h.get_by_label("Brush Tool (B)").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.tool), Some(SmTool::Brush)); + assert!(h.query_by_label_contains("Hardness").is_some(), "the Brush's options bar"); + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().distort.select_mask.is_none(), "OK closes"); + let app = h.state(); + assert!(cov(app, 5, 5) < 0.01 && cov(app, 100, 50) > 0.99, "the inverted selection"); +} + +/// Panning shows Photoshop's hand: open while Space is held (or the Hand tool is picked), closed +/// while dragging; and Space doesn't press the panel button clicked last. +#[test] +fn panning_shows_the_hand() { + use egui::{PointerButton, Pos2}; + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": "brush"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::distort_ui::keys(app, &ctx); + keys(app, &ctx); + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // Click Invert, then move over the image. + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + let over = Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the brush's own cursor"); + let space = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, pressed: bool| { + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + space(&mut h, true); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "Space: the open hand"); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1), "Space didn't press Invert again"); + let centre = h.state().distort.select_mask.as_ref().map(|w| w.center).unwrap(); + h.event(egui::Event::PointerButton { pos: over, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for dx in [5.0, 20.0, 40.0] { + h.event(egui::Event::PointerMoved(Pos2::new(over.x + dx, over.y))); + h.run_steps(1); + } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "dragging: the closed hand"); + h.event(egui::Event::PointerButton { pos: Pos2::new(over.x + 40.0, over.y), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + space(&mut h, false); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.center[0] < centre[0], "the view panned: {:?} → {:?}", centre, w.center); + assert!(w.edits.len() == 1 && w.strokes.is_empty(), "panning painted nothing"); + // The Hand tool shows the open hand without Space. + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Hand); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab); +} + +/// The greyed Object Aware and Refine Hair say why they can't be used. +#[test] +fn greyed_controls_explain_themselves() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for (label, why) in [("Object Aware", "segmentation model"), ("Refine Hair", "hair detection")] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} is greyed"); + assert!(h.query_by_label_contains(why).is_none(), "{label}: no tooltip before hovering"); + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label_contains(why).is_some(), "{label}: hovering says why"); + h.hover_at(egui::pos2(700.0, 450.0)); + h.run_steps(4); + } +} diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs index 9f5440c4aa..7ef5de6ddd 100644 --- a/crates/ui-egui/src/select_mask_view.rs +++ b/crates/ui-egui/src/select_mask_view.rs @@ -57,7 +57,7 @@ impl Mode { Mode::ALL.into_iter().find(|m| m.id() == id) } - fn label(self) -> &'static str { + pub(crate) fn label(self) -> &'static str { match self { Mode::OnionSkin => tl!("Onion Skin"), Mode::MarchingAnts => tl!("Marching Ants"), @@ -70,7 +70,7 @@ impl Mode { } /// The single-letter key that picks this view. - fn key(self) -> egui::Key { + pub(crate) fn key(self) -> egui::Key { match self { Mode::OnionSkin => egui::Key::O, Mode::MarchingAnts => egui::Key::M, @@ -83,7 +83,7 @@ impl Mode { } /// The view's opacity (percent) until it is changed; `None` for views without one. - fn default_opacity(self) -> Option { + pub(crate) fn default_opacity(self) -> Option { match self { Mode::OnionSkin | Mode::Overlay => Some(50.0), Mode::OnBlack | Mode::OnWhite => Some(100.0), @@ -91,7 +91,7 @@ impl Mode { } } - fn next(self) -> Mode { + pub(crate) fn next(self) -> Mode { let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0); Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin) } @@ -425,6 +425,10 @@ struct View { opacity: f32, show_edge: bool, show_original: bool, + /// Overlay's colour (Photoshop's default rubylith red) and whether it marks the selected + /// areas rather than the masked ones. + color: Color, + selected: bool, } impl View { @@ -436,6 +440,16 @@ impl View { opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32, show_edge: b("showEdge"), show_original: b("showOriginal"), + color: v + .and_then(|v| v.get("color")) + .and_then(Value::as_array) + .filter(|c| c.len() == 3) + .and_then(|c| { + let ch = |i: usize| c.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32); + Some(Color::rgb(ch(0)?, ch(1)?, ch(2)?)) + }) + .unwrap_or(Color::rgb(1.0, 0.0, 0.0)), + selected: v.and_then(|v| v.get("indicates")).and_then(Value::as_str) == Some("selected"), } } } @@ -448,9 +462,21 @@ fn length(params: &Value, key: &str, k: u32) -> f64 { /// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the /// requested view. `None` when the refinement fails or is cancelled. pub fn preview_document(doc: &Document, active: Option, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option { + let k = k.max(1); + preview_proxy(crate::proxy::proxy_document(doc, k), active, params, k, cancel) +} + +/// [`preview_document`] on `proxy`, the document already reduced by `k` (the Select and Mask +/// workspace builds it itself, its selection already carrying the workspace's tool edits). +pub fn preview_proxy( + mut proxy: Document, + active: Option, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { let view = View::of(params); let k = k.max(1); - let mut proxy = crate::proxy::proxy_document(doc, k); let active = active.filter(|id| proxy.layer(*id).is_some()); // What Select and Mask starts from: the selection, else the active layer's mask. let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone())); @@ -546,7 +572,10 @@ fn show(doc: &mut Document, active: Option, view: &View, coverage: &Cov doc.selection = Some(coverage.surface(|v| v)); match view.mode { Mode::MarchingAnts => {} - Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))), + Mode::Overlay => { + let marked = if view.selected { coverage.surface(|v| v) } else { coverage.surface(|v| 1.0 - v) }; + doc.layers.push(veil("Overlay", view.color, o, Some(marked))); + } Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::BlackWhite => { @@ -692,6 +721,24 @@ mod tests { assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area"); } + /// Overlay takes Photoshop's colour swatch and Indicates: the masked (default) or the selected + /// areas carry the tint. + #[test] + fn overlay_colour_and_what_it_indicates() { + let doc = half_selected(); + let active = doc.layers.first().map(|l| l.id); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02); + let view = |indicates: &str| json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": [0.0, 0.0, 1.0], "indicates": indicates}}); + let masked = preview_document(&doc, active, &view("masked"), 1, None).expect("a preview"); + assert!(near(&pixel(&masked, 35, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&masked, 5, 10), &[1.0, 1.0, 1.0, 1.0])); + let selected = preview_document(&doc, active, &view("selected"), 1, None).expect("a preview"); + assert!(near(&pixel(&selected, 5, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&selected, 35, 10), &[1.0, 1.0, 1.0, 1.0])); + // A hostile colour falls back to red. + let bad = json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": ["x", 2, null]}}); + let red = preview_document(&doc, active, &bad, 1, None).expect("a preview"); + assert!(near(&pixel(&red, 35, 10), &[1.0, 0.0, 0.0, 1.0])); + } + /// Show Edge shows the band around the selection's edge; Show Original the unrefined selection. #[test] fn show_edge_and_show_original() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3300b9d92e..3e1def57ae 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -472,11 +472,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.caf.is_some() && !ctx.text_edit_focused() { crate::caf_ui::keys(app, ctx); } + // And Select and Mask's tools, views and brush size. + if app.distort.select_mask.is_some() && !ctx.text_edit_focused() { + crate::select_mask_ui::keys(app, ctx); + } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); // Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a // focused text field keeps its keys. - let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some()) && focus != Focus::None; + let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some() || app.distort.select_mask.is_some()) && focus != Focus::None; if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() { if focus != Focus::Text { nav_keys(app, ctx); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e60af2136..c6f020ebe9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -70,6 +70,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. + - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - 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. diff --git a/docs/gaps.md b/docs/gaps.md index 5b5a04413b..d7914161a0 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-22 · **Change:** minor (G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (G7: Select and Mask is Photoshop's workspace with its own tools, #7069, #8148, TOOL-214-1 still partial; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -69,8 +69,10 @@ what an agent can't finish alone. - **Estimate:** 60–100 h (2–9 h each, per the calibration). ### G7. Workspaces shown as parameter dialogs -- **Missing:** Select and Mask workspace layout - (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area +- **Missing:** Select and Mask's Refine Hair (#2671), Object Aware refine mode (#2788) and Presets + (TOOL-214-1; the workspace landed 2026-10-23, #7069, #8148: Photoshop's layout with its own + Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso and Polygonal Lasso, Hand and + Zoom, the view modes since #1754, Real-Time Refinement and High Quality Preview); Content-Aware Fill workspace with a paintable sampling area (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14). Content-Aware Move's live preview of the result is done (TOOL-213-4, 2026-10-16, #6680: every selection the command takes, coarse first for large ones, then the exact result), as are Transform On Drop and Content-Aware Scale @@ -346,6 +348,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | G7: Select and Mask workspace (Photoshop's layout, its tools as `select.refineEdge` edits, Real-Time Refinement, High Quality Preview), #7069, #8148; Refine Hair, Object Aware and Presets remain; no estimate changed | | 2026-10-22 | minor | G1: 32-bit Hue/Saturation unclipped with Photoshop's saturation factor, bright colours keep their hue, Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) | | 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-21 | minor | G11: Batch and droplets take the result at the action's recorded Save / Save As step (Photoshop's "Override Action 'Save As' Commands"); estimate unchanged | diff --git a/docs/scorecard.md b/docs/scorecard.md index be32d94aee..adca3df683 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -149,7 +149,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6af89fada..836078eb11 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-21 · **Change:** minor (compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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-23 · **Change:** minor (Select and Mask workspace with its own tools, #7069, #8148, percentages not re-measured; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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); @@ -75,7 +75,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| -| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | +| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces (Select and Mask is one since 2026-10-23); type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | | UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 27 of 156 preferences do nothing (was 46 of 149); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | @@ -168,7 +168,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: 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 | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), no Refine Hair (#2671), Object Aware or Presets; 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); no live stroke preview for the Mixer Brush yet (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Select and Mask is Photoshop's workspace with its own tools (#7069, #8148); TOOL-214-1 stays partial (Refine Hair, Object Aware, Presets); percentages and estimates unchanged | | 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-19 | minor | JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); percentages unchanged | | 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 | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 2ec39ab4d9..6e2bd77e1c 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" [[item]] id = "TOOL-214-2" From 9df6c39da4fc68fbbbc9ed64f06b2793e74206f6 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 21:23:43 +0530 Subject: [PATCH 09/28] fix(ui): Select and Mask keeps its work on a second open and drops refused strokes - Opening Select and Mask while it is open is an error, not a fresh workspace that silently discards the open one's edits, strokes and settings. - A tool edit or Refine Edge Brush stroke refused at the engine's limits no longer stays drawn: the trail is cleared and the preview is rebuilt without it. Addresses CodeRabbit review on #8896. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/select_mask_ui.rs | 19 +++++++++--- crates/ui-egui/src/select_mask_ui_tests.rs | 35 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index baf06a4a9f..323c613246 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -506,14 +506,21 @@ impl SelectMaskWorkspace { list.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum() } + /// An item the engine's limits refuse: say why, and drop what was drawn for it. + fn reject(&mut self, why: String) { + self.error = Some(why); + self.trail = None; + self.stale(); + } + /// Adds a tool edit (undoable), within the engine's limits. fn add_edit(&mut self, e: Value) { if self.edits.len() >= MAX_EDITS { - self.error = Some(format!("at most {MAX_EDITS} edits: undo some, or reset")); + self.reject(format!("at most {MAX_EDITS} edits: undo some, or reset")); return; } if Self::points_in(&self.edits) + Self::points_in(std::slice::from_ref(&e)) > MAX_EDIT_POINTS { - self.error = Some(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); + self.reject(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); return; } self.error = None; @@ -525,11 +532,11 @@ impl SelectMaskWorkspace { /// Adds a Refine Edge Brush stroke (undoable), within the engine's limits. fn add_stroke(&mut self, s: Value) { if self.strokes.len() >= MAX_STROKES { - self.error = Some(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); + self.reject(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); return; } if Self::points_in(&self.strokes) + Self::points_in(std::slice::from_ref(&s)) > MAX_STROKE_POINTS { - self.error = Some(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); + self.reject(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); return; } self.error = None; @@ -699,6 +706,10 @@ fn reset(w: &mut SelectMaskWorkspace) { /// Opens the workspace on the selection (or the active layer's mask). pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + // Opening again would drop the open workspace's work: edit it with `ui`, or cancel it. + if app.distort.select_mask.is_some() { + return Err("Select and Mask is already open".into()); + } if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); } diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index db4b3cc913..3ed398e271 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -296,6 +296,41 @@ fn real_time_refinement_previews_the_stroke_being_drawn() { } } +/// A stroke refused at the engine's limits leaves nothing drawn: no trail, and a preview without it. +#[test] +fn a_refused_stroke_leaves_no_trail_or_preview() { + for tool in ["quick", "refineEdge"] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": tool})); + let w = ws_mut(&mut app); + if tool == "quick" { + w.edits = vec![json!({"tool": "invert"}); MAX_EDITS]; + } else { + w.strokes = vec![json!({"points": []}); MAX_STROKES]; + } + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let want = ws(&app).preview.want; + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + let w = ws(&app); + assert!(w.error.as_deref().is_some_and(|e| e.starts_with("at most")), "{tool}: {:?}", w.error); + assert!(w.trail.is_none(), "{tool}: the refused stroke is not drawn"); + assert!(w.preview.want > want, "{tool}: a new preview drops the refused stroke"); + } +} + +/// Opening again while the workspace is open keeps its work and says why. +#[test] +fn opening_again_keeps_the_open_workspace() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let e = crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})); + assert!(e.is_err_and(|e| e.contains("already open"))); + assert_eq!(ws(&app).edits.len(), 1, "the edit survives"); +} + /// The preview keeps the selection after the edits it applied and starts the next from there: /// after undo it starts over. #[test] From eda6e71835e3c7e20995e1754d3ee9d33ad75ec5 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 09:22:48 +0530 Subject: [PATCH 10/28] fix(ui): Select and Mask applies its work only to the document and layer it opened on The workspace kept the document's index and the layer, but OK ran select.refineEdge on whatever was active. A document switch and OK queued on the control channel with no frame between them applied the first document's edits to the second, and selecting another layer in the same document sent a layer-mask OK to that layer. The workspace now stores its DocId. OK, control requests, the preview and pointer events check that the active document and layer are the ones it opened on; when they are not, the workspace closes with the reason in the status bar and nothing is edited. distort_ui's cancel_stale drops it like Liquify and Puppet, and the pointer route has the same on_doc guard (review by FredApps and aiongg on #8896). --- crates/ui-egui/src/select_mask_ui.rs | 51 +++++++++++-- crates/ui-egui/src/select_mask_ui_tests.rs | 85 ++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 323c613246..8b406ab85c 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; -use photocraft_doc::{Document, LayerId}; +use photocraft_doc::{DocId, Document, LayerId}; use photocraft_engine::select_mask_edits::{self, MAX_EDIT_POINTS, MAX_EDITS}; use photocraft_engine::smartselect_cmds::{MAX_STROKE_POINTS, MAX_STROKES}; use photocraft_geom::Rect; @@ -388,8 +388,9 @@ struct Preview { } pub struct SelectMaskWorkspace { + /// The document and layer the workspace was opened on: its work applies to them only. layer: Option, - doc_index: usize, + pub(crate) document: DocId, canvas: Rect, /// The document revision the textures were made from. revision: u64, @@ -713,10 +714,10 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); } - let doc_index = app.session.active_index().ok_or("no document")?; // A targeted layer mask is what is refined (Photoshop opens on it from the mask's thumbnail). let target_mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document")?; + let document = st.doc.id; let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); let canvas = st.doc.bounds(); let revision = st.revision; @@ -732,7 +733,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { } let mut w = SelectMaskWorkspace { layer, - doc_index, + document, canvas, revision, target_mask, @@ -769,6 +770,31 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { Ok(()) } +/// Why the open workspace's work no longer applies: the active document or layer is not the one it +/// was opened on (another tab, or another layer selected by a shortcut or the control channel). +/// `select.refineEdge` works on the active document and layer, so its edits must not run there. +pub(crate) fn stale(app: &PhotocraftApp) -> Option<&'static str> { + let w = app.distort.select_mask.as_ref()?; + let Some(st) = app.session.active().filter(|st| st.doc.id == w.document) else { + return Some("Select and Mask was closed: its document is no longer the active one"); + }; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + (layer != w.layer).then_some("Select and Mask was closed: another layer was selected") +} + +/// Closes a [`stale`] workspace (its work is dropped, the new target untouched): the reason. +fn drop_stale(app: &mut PhotocraftApp) -> Result<(), String> { + match stale(app) { + Some(why) => { + close(app); + app.ui.status = why.into(); + app.ui.status_error = true; + Err(why.into()) + } + None => Ok(()), + } +} + /// The params OK runs `select.refineEdge` with. fn commit_params(w: &SelectMaskWorkspace) -> Value { let mut p = w.opts.refine_params(); @@ -793,6 +819,7 @@ fn remember(app: &mut PhotocraftApp, opts: &SmOpts) { /// OK: runs `select.refineEdge` and closes. A failure leaves the workspace open with the error in /// its panel, so nothing painted is lost. pub fn commit(app: &mut PhotocraftApp) -> Result { + drop_stale(app)?; let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; w.end_gesture(); w.close_polygon(); @@ -861,6 +888,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { cancel(app); return Ok(json!({"cancelled": true})); } + drop_stale(app)?; { let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; let before = w.opts.clone(); @@ -986,6 +1014,9 @@ struct Job { } fn job(app: &PhotocraftApp) -> Option { + if stale(app).is_some() { + return None; + } let w = app.distort.select_mask.as_ref()?; let st = app.session.active()?; let k = w.proxy_k(); @@ -1142,6 +1173,7 @@ fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: R /// Computes the preview now, on this thread (tests and the control channel). fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + drop_stale(app)?; let j = job(app).ok_or("Select and Mask is not open")?; let revision = j.revision; let r = compute(j, None); @@ -1213,6 +1245,9 @@ fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context) { /// Pointer in document coordinates (from the view or the control channel). pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + if drop_stale(app).is_err() { + return; + } let Some(w) = app.distort.select_mask.as_mut() else { return }; match ev { ToolEvent::Down { x, y, .. } if w.tool == SmTool::Zoom => { @@ -1505,7 +1540,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { w.stale(); } upload(app, ctx); - let title = app.distort.select_mask.as_ref().map(|w| crate::canvas::tab_title_at(app, w.doc_index, if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 })); + let title = app.distort.select_mask.as_ref().map(|w| { + crate::canvas::tab_title_at( + app, + app.session.documents().iter().position(|d| d.doc.id == w.document).unwrap_or(0), + if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 }, + ) + }); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let mut action: Option<&str> = None; diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index 3ed398e271..edb3602f8c 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -577,3 +577,88 @@ fn greyed_controls_explain_themselves() { h.run_steps(4); } } + +fn call(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let (req, _rx) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &req) { + crate::control::Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } +} + +/// A second document with its own selection, the first one active again. +fn two_documents() -> PhotocraftApp { + let mut app = app_with_selection(); + app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap(); + app.session.execute("select.rect", json!({"x": 100, "y": 50, "width": 30, "height": 30})).unwrap(); + assert!(app.session.set_active(0)); + app.sync_views(); + app +} + +fn fingerprint(app: &PhotocraftApp, i: usize) -> (u64, Option, Option) { + let st = &app.session.documents()[i]; + let sel = st.doc.selection.as_ref(); + (st.revision, sel.map(|s| s.sample_channel(10, 10, 0)), sel.map(|s| s.sample_channel(120, 50, 0))) +} + +/// The #8896 review: a document switch and OK queued on the control channel, with no frame +/// between them, must not apply the first document's edits to the second. +#[test] +fn ok_after_a_document_switch_changes_neither_document() { + let mut app = two_documents(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let before = (fingerprint(&app, 0), fingerprint(&app, 1)); + let switched = call(&mut app, "ui.menu.invoke", json!({"command": "window.document.1"})); + assert_eq!(app.session.active_index(), Some(1), "{switched}"); + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + assert_eq!((fingerprint(&app, 0), fingerprint(&app, 1)), before, "neither document changed"); + assert!(app.distort.select_mask.is_none(), "the stale workspace is closed"); +} + +/// Every route into the workspace checks its document: control edits, the preview and pointer +/// events on another document are refused, and none of them edits that document. +#[test] +fn control_preview_and_pointer_on_another_document_are_refused() { + for route in ["control", "preview", "pointer"] { + let mut app = two_documents(); + open(&mut app).unwrap(); + let before = fingerprint(&app, 1); + assert!(app.session.set_active(1)); + match route { + "control" => assert!(control(&mut app, &json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})).is_err()), + "preview" => assert!(control(&mut app, &json!({"waitPreview": true})).is_err()), + _ => { + let ev = ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }; + crate::distort_ui::pointer(&mut app, ev, egui::Modifiers::NONE); + } + } + assert!(app.distort.select_mask.is_none(), "{route}: the stale workspace is closed"); + assert_eq!(fingerprint(&app, 1), before, "{route}: the other document is untouched"); + } +} + +/// Selecting another layer in the same document (a shortcut, or the control channel between +/// requests) must not send a layer-mask OK to that layer. +#[test] +fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { + let mut app = app_with_selection(); + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + ui(&mut app, json!({"output": "layerMask"})); + let revision = app.session.active().unwrap().revision; + call(&mut app, "engine.execute", json!({"command": "layer.select", "params": {"layer": other.0}})); + let revision_after_select = app.session.active().unwrap().revision; + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(other).unwrap().mask.is_none(), "no mask on the newly selected layer"); + assert!(st.doc.layer(first).unwrap().mask.is_none(), "nor on the first"); + assert!(st.doc.selection.is_some(), "the selection stands"); + assert!(revision_after_select >= revision && st.revision == revision_after_select, "nothing was edited after the switch"); + assert!(app.distort.select_mask.is_none()); +} From 3450ca20bc9a2003ea39162367c9c540d084633b Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 11:02:59 +0530 Subject: [PATCH 11/28] select-mask: bind the preview's base cache to the document revision A preview job still running when the document changed stored its base selection back, and the next preview reused it for the new pixels. BaseCache now records its revision and compute reuses it only on a match. Also describe TOOL-214-1 as the full-window workspace. --- crates/ui-egui/src/select_mask_ui.rs | 8 +++--- crates/ui-egui/src/select_mask_ui_tests.rs | 30 ++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 8b406ab85c..be61327fd9 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -346,6 +346,8 @@ struct Gesture { #[derive(Clone)] struct BaseCache { k: u32, + /// The document revision the base was computed from. + revision: u64, edits: Vec, base: Option, } @@ -1105,16 +1107,16 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result select_mask_edits::parse(&json!({ "edits": list })).map_err(|e| e.to_string()) }; // With no edits yet, the selection Select and Mask starts from is where the first one starts. - let mut cache = BaseCache { k, edits: Vec::new(), base: start() }; + let mut cache = BaseCache { k, revision: job.revision, edits: Vec::new(), base: start() }; if !job.edits.is_empty() || job.extra.is_some() { // The selection after the edits already applied, when they still lead the list. - let (from, done) = match job.cache.filter(|c| c.k == k && job.edits.starts_with(&c.edits)) { + let (from, done) = match job.cache.filter(|c| c.k == k && c.revision == job.revision && job.edits.starts_with(&c.edits)) { Some(c) => (c.base, c.edits.len()), None => (start(), 0), }; let rest = scaled(job.edits.get(done..).unwrap_or_default())?; let base = select_mask_edits::apply(&doc, layer, from, &rest); - cache = BaseCache { k, edits: job.edits.clone(), base: base.clone() }; + cache = BaseCache { k, revision: job.revision, edits: job.edits.clone(), base: base.clone() }; let base = match &job.extra { Some(e) => select_mask_edits::apply(&doc, layer, base, &scaled(std::slice::from_ref(e))?), None => base, diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index edb3602f8c..f33c69c078 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -349,6 +349,36 @@ fn the_preview_reuses_the_edits_it_applied() { assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); } +/// The selection the edits made on an older document is not reused: a Quick Selection stroke +/// on a repainted image previews what OK would commit. +#[test] +fn the_preview_recomputes_the_edits_after_the_document_changes() { + let warm = |app: &mut PhotocraftApp| { + app.session + .edit("paint", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 160, 100), &[0.85, 0.55, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + }; + let stroke = json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}}); + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, stroke.clone()); + let two_tone = selected(&mut app); + // The pixels change under the workspace (a control-channel command) before it redraws. + warm(&mut app); + let after = selected(&mut app); + let mut fresh = app_with_selection(); + warm(&mut fresh); + open(&mut fresh).unwrap(); + ui(&mut fresh, stroke); + let expected = selected(&mut fresh); + assert_ne!(two_tone, expected, "the repaint changes what the stroke selects"); + assert_eq!(after, expected); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.revision), app.session.active().map(|s| s.revision)); +} + /// Sends `key` with `modifiers` through the workspace's key handling. fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; diff --git a/docs/scorecard.md b/docs/scorecard.md index 8b73d00e2b..2bc5f8bc3e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -153,7 +153,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Photoshop's full-window Select and Mask workspace (select_mask_ui.rs, #7069, #8148); OK applies select.refineEdge. Live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace has the options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6e2bd77e1c..15be8c414f 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" +note = "Photoshop's full-window Select and Mask workspace (select_mask_ui.rs, #7069, #8148); OK applies select.refineEdge. Live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace has the options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" [[item]] id = "TOOL-214-2" From af5bf81f90768cf833e178a62f029b7d899880dc Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 11:53:15 +0530 Subject: [PATCH 12/28] select-mask: refuse requests whole, drop old-revision previews, no brush on an empty selection - control: tool, mode and stroke are parsed before anything changes, so a refused request leaves the workspace as it was. - A preview made from an older document revision is dropped (even mid-stroke), and a revision change clears the shown texture and the pending result too. - Show Edge applies the Refine Edge Brush only when the selection has content, as OK (matting::refine_area) does. - control-protocol.md documents `hideView`. --- crates/ui-egui/src/select_mask_ui.rs | 57 ++++++++++++++++------ crates/ui-egui/src/select_mask_ui_tests.rs | 51 +++++++++++++++++++ crates/ui-egui/src/select_mask_view.rs | 5 +- docs/control-protocol.md | 2 +- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index be61327fd9..8c0295c971 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -894,18 +894,36 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { { let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; let before = w.opts.clone(); + // Everything is read before anything changes, so rejected params change nothing. let mut o = w.opts.clone(); o.apply(ui)?; - if let Some(t) = ui.get("tool").and_then(Value::as_str) { - let t = SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?; + let tool = match ui.get("tool").and_then(Value::as_str) { + Some(t) => Some(SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?), + None => None, + }; + let add = match ui.get("mode").and_then(Value::as_str) { + None => None, + Some("add") => Some(true), + Some("subtract") => Some(false), + Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + }; + let stroke = match ui.get("stroke") { + Some(s) => { + let pts: Vec<[f64; 2]> = + serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { + return Err("bad stroke points: not finite".into()); + } + Some((pts, s.get("alt").and_then(Value::as_bool).unwrap_or(false))) + } + None => None, + }; + if let Some(t) = tool { w.pick(t); } w.opts = o; - match ui.get("mode").and_then(Value::as_str) { - None => {} - Some("add") => w.add = true, - Some("subtract") => w.add = false, - Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + if let Some(add) = add { + w.add = add; } if let Some(n) = ui.get("size").and_then(Value::as_f64) { w.set_size(n); @@ -922,12 +940,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if w.opts != before { w.stale(); } - if let Some(s) = ui.get("stroke") { - let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; - if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { - return Err("bad stroke points: not finite".into()); - } - let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if let Some((pts, alt)) = stroke { if w.tool == SmTool::PolygonalLasso { w.poly = Some((w.add != alt, pts)); w.close_polygon(); @@ -1149,6 +1162,10 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result /// still the state wanted (or a stroke is being drawn: Real-Time Refinement shows the latest one /// it has), and the selection after the edits and the reduced copy for the next preview. fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: Result<(Shown, BaseCache, Arc), String>) { + // Made from pixels the document no longer has (even mid-stroke). + if revision != w.revision { + return; + } let pv = &mut w.preview; match r { Ok((shown, cache, proxy)) => { @@ -1176,6 +1193,7 @@ fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: R /// Computes the preview now, on this thread (tests and the control channel). fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { drop_stale(app)?; + follow_revision(app); let j = job(app).ok_or("Select and Mask is not open")?; let revision = j.revision; let r = compute(j, None); @@ -1529,18 +1547,25 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt edit } -/// Draws the workspace (a full-window layer over the app). -pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { - // The document changed under the workspace: new textures, a new preview. +/// The document changed under the workspace: new textures, a new preview, and nothing of the old +/// one shown or reused. +fn follow_revision(app: &mut PhotocraftApp) { let rev = app.session.active().map(|s| s.revision); if let Some(w) = app.distort.select_mask.as_mut() && Some(w.revision) != rev { w.revision = rev.unwrap_or(w.revision); w.image_tex = None; + w.preview.tex = None; + w.preview.pending = None; w.preview.cache = None; w.stale(); } +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + follow_revision(app); upload(app, ctx); let title = app.distort.select_mask.as_ref().map(|w| { crate::canvas::tab_title_at( diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index f33c69c078..2a4c484530 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -379,6 +379,57 @@ fn the_preview_recomputes_the_edits_after_the_document_changes() { assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.revision), app.session.active().map(|s| s.revision)); } +/// A request with a bad field is refused whole: the valid fields before it change nothing either. +#[test] +fn a_refused_request_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = ws(&app).describe(); + for bad in [ + json!({"tool": "brush", "mode": "invalid"}), + json!({"tool": "brush", "mode": "subtract", "size": 80, "radius": 40, "stroke": {"points": "x"}}), + json!({"hardness": 10, "sampleAllLayers": true, "hideView": true, "stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + assert_eq!(ws(&app).describe(), before, "{bad}"); + assert!(!ws(&app).hide_view && !ws(&app).sample_all, "{bad}"); + } +} + +/// With nothing selected OK refines nothing, so Show Edge shows no Refine Edge Brush stroke. +#[test] +fn show_edge_shows_no_stroke_without_a_selection() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true, "showEdge": true})); + let empty = selected(&mut app); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[40.0, 20.0], [40.0, 80.0]]}})); + assert_eq!(selected(&mut app), empty); + ui(&mut app, json!({"commit": true})); + assert_eq!(cov(&app, 40, 50), 0.0, "and OK selects nothing there"); +} + +/// A preview of pixels the document no longer has is never shown, even mid-stroke, and the old one +/// goes when the document changes. +#[test] +fn a_preview_of_an_old_revision_is_dropped() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + selected(&mut app); + assert!(ws(&app).preview.pending.is_some()); + let j = job(&app).unwrap(); + let old = j.revision; + let late = compute(j, None); + app.session.execute("select.all", json!({})).unwrap(); + follow_revision(&mut app); + assert!(ws(&app).preview.pending.is_none() && ws(&app).preview.tex.is_none(), "the old preview is gone"); + let w = ws_mut(&mut app); + w.pressed = true; + let generation = w.preview.want; + take_result(w, generation, old, late); + assert!(w.preview.pending.is_none() && w.preview.cache.is_none() && w.preview.shown != generation); +} + /// Sends `key` with `modifiers` through the workspace's key handling. fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs index 7ef5de6ddd..c657ac3fd3 100644 --- a/crates/ui-egui/src/select_mask_view.rs +++ b/crates/ui-egui/src/select_mask_view.rs @@ -483,7 +483,10 @@ pub fn preview_proxy( let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX)); let coverage = if view.show_edge { let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize); - band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas) + // With nothing selected OK refines nothing (`matting::refine_area`), so the brush shows nothing either. + let selects = original.as_ref().is_some_and(|s| !s.content_bounds().intersect(&canvas).is_empty()); + let brush = if selects { scaled_strokes(params, k).and_then(|s| photocraft_algo::matting::EdgeBrush::new(&s, canvas)) } else { None }; + band.with_brush(brush.as_ref(), canvas) } else if view.show_original { Coverage::read(original.as_ref(), canvas) } else { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a5132ce3e9..07f6c7c033 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -71,7 +71,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work. - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). + - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `hideView` (true shows the image without the view, as holding X does), `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - 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. From 254e501de952b1c4da2ec9b8d84b2f0768cc65ad Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 14:47:13 +0530 Subject: [PATCH 13/28] fix(engine): a recorded several-layer Free Transform replays on the selection The canvas box commits `edit.transform` with the `layers` it opened on, and the Action recorder kept those ids verbatim, so replaying in another document failed with NoLayer or moved unrelated layers. When the listed layers are the ones the selection gives the command, the recorded step drops `layers` and replays on the layers selected then; other explicit lists keep their ids. Claude-Session: https://claude.ai/code/session_01D8r6W6haR5FWJyxUakMEfA --- crates/engine/src/layer_multi_cmds.rs | 24 +++++++++++++ crates/engine/src/transform_cmds.rs | 13 ++++--- crates/engine/src/transform_multi_tests.rs | 40 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 47114f7e5d..48183e0b10 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -211,6 +211,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio if id == "layer.moveTo" { return recorded_move(s, p, pre_selected); } + if id == "edit.transform" { + return recorded_transform(s, p, pre_selected); + } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { Some(sel) => sel == [layer_id], @@ -226,6 +229,27 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio p } +/// A Free Transform of several layers lists their ids (the box's session keeps them while it is +/// open). When they are the layers the selection gave it, the step drops `layers`, so it replays on +/// the layers selected then, in any document, rather than on these ids (#6501). +fn recorded_transform(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value { + let Some(st) = s.active() else { return p }; + let Some(listed) = p.get("layers").and_then(Value::as_array) else { return p }; + let Some(listed) = listed.iter().map(|v| v.as_u64().map(LayerId)).collect::>>() else { return p }; + let selected = pre_selected.map_or_else(|| st.selected_layers(), <[LayerId]>::to_vec); + let mut moved = top_level(&st.doc, &listed); + let mut targets = crate::transform_cmds::targets_of(&st.doc, &selected, st.active_layer); + moved.sort_unstable(); + moved.dedup(); + targets.sort_unstable(); + if moved == targets + && let Some(fields) = p.as_object_mut() + { + fields.remove("layers"); + } + p +} + fn selection_target(s: &Session, p: &Value) -> Result { let Some(name) = p.get("name") else { if p.get("occurrence").is_some() { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 48e8abe219..29dc01f83d 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -268,12 +268,17 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { /// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or /// only the Background besides it), that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { - let selected = st.selected_layers(); + targets_of(&st.doc, &st.selected_layers(), st.active_layer) +} + +/// [`selection_targets`] for the layers `selected`, `active` the active one: what an `edit.transform` +/// without `layer` or `layers` moves, so a recorded step can drop a `layers` list that matches it. +pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option) -> Vec { if selected.len() < 2 { - return st.active_layer.into_iter().collect(); + return active.into_iter().collect(); } - let ids: Vec = crate::layer_multi_cmds::top_level(&st.doc, &selected).into_iter().filter(|id| !st.doc.is_background(*id)).collect(); - if ids.is_empty() { st.active_layer.into_iter().collect() } else { ids } + let ids: Vec = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + if ids.is_empty() { active.into_iter().collect() } else { ids } } /// The `layers` param: the layers one box transforms together (at most 10000, each in the diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index ad34af7937..e26572d6e7 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -241,3 +241,43 @@ fn straighten_layer_rotates_every_selected_layer() { assert!(bounds(&s, paint) != p0); assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); } + +#[test] +fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_another_document() { + let (mut s, paint, text) = two_layers(); + s.execute("actions.record", json!({"name": "Move both"})).unwrap(); + // What the canvas box commits: the ids it opened on, listed (#6501). + s.execute("edit.transform", json!({"layers": [paint.0, text.0], "matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let step = s.actions.list[0].steps[0].1.clone(); + assert!(step.get("layers").is_none(), "the step plays on the selection, not on these ids: {step}"); + // Another document, its ids shifted by an extra layer under the two that get selected. + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let spare = LayerId(s.execute("layer.new.layer", json!({"name": "Spare"})).unwrap()["layer"].as_u64().unwrap()); + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + assert_ne!((a, b), (paint, text), "the ids differ from the recorded ones"); + s.edit("paint", |doc, _| { + for (id, x) in [(spare, 100), (a, 10), (b, 60)] { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[0.0, 0.0, 1.0, 1.0]); + } + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let (a0, b0, spare0) = (bounds(&s, a), bounds(&s, b), bounds(&s, spare)); + let r = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a0.translate(30, 40), b0.translate(30, 40)), "the selected layers moved"); + assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed"); +} + +#[test] +fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() { + let (mut s, paint, _) = two_layers(); + s.execute("actions.record", json!({"name": "Paint only"})).unwrap(); + s.execute("edit.transform", json!({"layers": [paint.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1["layers"], json!([paint.0])); +} From 280f7efc0f1306afa613b5b8351c9e3655e84853 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 25 Oct 2026 17:17:47 +0530 Subject: [PATCH 14/28] Save for Web: WebP keeps the photo's EXIF when Metadata is All The WebP branch built its image from meta(), which carries only the resolution and XMP, and embed_metadata followed the XMP alone, so an EXIF-only photo lost its camera data in both lossy and lossless modes. The filtered EXIF now goes onto the image and enables the metadata when either it or the XMP is present. Regression: an EXIF-only photo exported as WebP (both modes) keeps its EXIF with All and drops it for every other choice. --- crates/engine/src/web_cmds.rs | 6 ++++-- crates/engine/src/web_cmds/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 5df3e5237c..db22aeb20b 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -512,12 +512,14 @@ pub fn optimize( WebFormat::Webp => { let (data, layout) = true_color(); let icc = icc.filter(|_| st.embed_icc); - let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); + // WebP holds EXIF (the metadata choice's "All" passes it in), so it rides along with the XMP. + let webp_meta = photocraft_codecs::Metadata { exif: exif.map(<[u8]>::to_vec), ..meta() }; + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(webp_meta); let opts = photocraft_codecs::EncodeOptions { webp_lossless: st.lossless, webp_quality: st.quality.min(100), embed_icc: icc.is_some(), - embed_metadata: xmp.is_some(), + embed_metadata: exif.is_some() || xmp.is_some(), ..Default::default() }; let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?; diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 8a87ad58bc..132e3e449a 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -1191,6 +1191,33 @@ fn save_for_web_all_writes_the_photos_exif() { assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); } +/// WebP holds EXIF in both modes: "All" keeps an EXIF-only photo's camera data (no XMP needed to +/// trigger the metadata), every other choice leaves it out. +#[test] +fn save_for_web_webp_all_keeps_an_exif_only_photos_exif() { + let dir = tmp("webp_exif"); + let mut s = session(8); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(camera_exif())); + Ok(()) + }) + .unwrap(); + for lossless in [false, true] { + for metadata in ["all", "none", "copyright", "allExceptCameraInfo"] { + let path = format!("{dir}/{metadata}-{lossless}.webp"); + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "metadata": metadata, "path": path})).unwrap(); + let meta = decode(&path).meta; + if metadata == "all" { + let exif = meta.exif.unwrap_or_else(|| panic!("EXIF kept (lossless {lossless})")); + assert_eq!(photocraft_algo::exif::read(&exif).make.as_deref(), Some("TestMake"), "lossless {lossless}"); + assert!(meta.xmp.is_none(), "the photo had no XMP"); + } else { + assert!(meta.exif.is_none(), "{metadata} leaves the camera data out (lossless {lossless})"); + } + } + } +} + #[test] fn quick_export_all_writes_the_photos_exif() { let dir = tmp("quick_exif"); From b61622c1e27f03a966c078cfecb505ffd0bf9255 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 25 Oct 2026 19:27:57 +0530 Subject: [PATCH 15/28] fix(ui): Select and Mask's stale-workspace messages are translated The two reasons stale() gives for closing the workspace were plain English in the status bar and the control error. They go through tl! now, with rows in every catalogue and a test that checks each language's status and error (#8896 review). --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/select_mask_ui.rs | 4 +-- crates/ui-egui/src/select_mask_ui_tests.rs | 29 ++++++++++++++++++++++ 23 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 54c85fe48c..ef178ad10e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4180,3 +4180,5 @@ cameraRaw Dehaze Odstranění oparu Grayscale Ramp Přechod stupňů šedi Current Colors Aktuální barvy Make Ramp Web Safe Omezit pruh na webové barvy + Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní + Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f235801397..282d86a29e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3448,3 +3448,5 @@ curves Smooth Glätten Grayscale Ramp Graustufenbalken Current Colors Aktuelle Farben Make Ramp Web Safe Farbbalken auf Webfarben beschränken + Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv + Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ccb2636fb1..ffd18db540 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4134,3 +4134,5 @@ curves Smooth Εξομάλυνση Grayscale Ramp Διαβάθμιση του γκρι Current Colors Τρέχοντα χρώματα Make Ramp Web Safe Μόνο χρώματα ιστού στη διαβάθμιση + Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό + Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 079704cefb..3244c70bcc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4396,3 +4396,5 @@ navigator Zoom Zoom Grayscale Ramp Rampa de escala de grises Current Colors Colores actuales Make Ramp Web Safe Hacer rampa segura para web + Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo + Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e05f8a7b43..9f43999a6a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4476,3 +4476,5 @@ shape Appearance Apparence Grayscale Ramp Barre de niveaux de gris Current Colors Couleurs actuelles Make Ramp Web Safe Limiter la barre aux couleurs Web + Select and Mask was closed: its document is no longer the active one Sélectionner et masquer a été fermé : son document n’est plus le document actif + Select and Mask was closed: another layer was selected Sélectionner et masquer a été fermé : un autre calque a été sélectionné diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 131c5aa131..fa5560f04d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -550,3 +550,5 @@ cameraRaw Yellow पीला View Mode दृश्य मोड Object Aware needs a segmentation model, which isn't available yet ऑब्जेक्ट-आधारित के लिए सेगमेंटेशन मॉडल चाहिए, जो अभी उपलब्ध नहीं है Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है + Select and Mask was closed: its document is no longer the active one चयन और मास्क बंद कर दिया गया: उसका दस्तावेज़ अब सक्रिय नहीं है + Select and Mask was closed: another layer was selected चयन और मास्क बंद कर दिया गया: दूसरी लेयर चुनी गई diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b533e8233c..2985f6a25e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4349,3 +4349,5 @@ cameraRaw Yellow Sárga Grayscale Ramp Szürkeárnyalatos sáv Current Colors Aktuális színek Make Ramp Web Safe Sáv korlátozása webes színekre + Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív + Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04549fe232..dd95ebb781 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3450,3 +3450,5 @@ curves Smooth Haluskan Grayscale Ramp Gradasi Grayscale Current Colors Warna Saat Ini Make Ramp Web Safe Jadikan Gradasi Aman untuk Web + Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif + Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 01cfefe485..c30194c888 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4551,3 +4551,5 @@ shape Appearance Aspetto Grayscale Ramp Barra scala di grigio Current Colors Colori correnti Make Ramp Web Safe Limita la barra ai colori web + Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo + Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8b3d843a29..0006b55dda 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4114,3 +4114,5 @@ curves Smooth スムーズ Grayscale Ramp グレースケールランプ Current Colors 現在のカラー Make Ramp Web Safe ランプを Web セーフにする + Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました + Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b645045d13..14d429fbfa 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3646,3 +3646,5 @@ curves Smooth 매끄럽게 Grayscale Ramp 회색 음영 램프 Current Colors 현재 색상 Make Ramp Web Safe 램프를 웹 안전 색상으로 만들기 + Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다 + Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9a6736410d..1b282f0168 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4194,3 +4194,5 @@ curves Smooth Vloeiend maken Grayscale Ramp Grijswaardenbalk Current Colors Huidige kleuren Make Ramp Web Safe Balk beperken tot webkleuren + Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document + Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 41e38d231f..015ee82525 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4052,3 +4052,5 @@ curves Smooth Wygładź Grayscale Ramp Pasek skali szarości Current Colors Bieżące kolory Make Ramp Web Safe Ogranicz pasek do kolorów internetowych + Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny + Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e18daa9010..0c32e27d89 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4364,3 +4364,5 @@ layerLabel No Color Sem cor Grayscale Ramp Rampa de tons de cinza Current Colors Cores atuais Make Ramp Web Safe Tornar rampa segura para a Web + Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo + Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cb79435980..cf7624caf0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4637,3 +4637,5 @@ shape Appearance Оформление Grayscale Ramp Шкала градаций серого Current Colors Текущие цвета Make Ramp Web Safe Сделать шкалу безопасной для web + Select and Mask was closed: its document is no longer the active one Окно «Выделение и маска» закрыто: его документ больше не активен + Select and Mask was closed: another layer was selected Окно «Выделение и маска» закрыто: выбран другой слой diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f206fe15e8..58a343f33f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4507,3 +4507,5 @@ shape Appearance Izgled Grayscale Ramp Skala nijansi sive Current Colors Trenutne boje Make Ramp Web Safe Učini skalu bezbednom za veb + Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan + Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2c7e258e69..145297c07d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4163,3 +4163,5 @@ cameraData Lens Lens Grayscale Ramp Gri Tonlama Şeridi Current Colors Geçerli Renkler Make Ramp Web Safe Şeridi Web Renkleriyle Sınırla + Select and Mask was closed: its document is no longer the active one Seç ve Maskele kapatıldı: belgesi artık etkin değil + Select and Mask was closed: another layer was selected Seç ve Maskele kapatıldı: başka bir katman seçildi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bc331838b5..cda7dfe93c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4145,3 +4145,5 @@ curves Smooth Згладити Grayscale Ramp Шкала градацій сірого Current Colors Поточні кольори Make Ramp Web Safe Зробити шкалу безпечною для вебу + Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний + Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fec6cf359..fd397830d0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3928,3 +3928,5 @@ curves Smooth Làm mượt Grayscale Ramp Dải thang xám Current Colors Màu hiện tại Make Ramp Web Safe Giới hạn dải màu ở màu web + Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động + Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 940f3b3dc6..65454a4fb2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3104,3 +3104,5 @@ curves Smooth 平滑 Exported {path} 已导出 {path} Exported {n} files 已导出 {n} 个文件 Driver: {driver} 驱动程序:{driver} + Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档 + Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2c79e9206b..488761d373 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3060,3 +3060,5 @@ curves Smooth 平滑 Grayscale Ramp 灰階色帶 Current Colors 目前色彩 Make Ramp Web Safe 讓色帶成為網頁安全色 + Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件 + Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層 diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 8c0295c971..353e57d275 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -778,10 +778,10 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { pub(crate) fn stale(app: &PhotocraftApp) -> Option<&'static str> { let w = app.distort.select_mask.as_ref()?; let Some(st) = app.session.active().filter(|st| st.doc.id == w.document) else { - return Some("Select and Mask was closed: its document is no longer the active one"); + return Some(tl!("Select and Mask was closed: its document is no longer the active one")); }; let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); - (layer != w.layer).then_some("Select and Mask was closed: another layer was selected") + (layer != w.layer).then_some(tl!("Select and Mask was closed: another layer was selected")) } /// Closes a [`stale`] workspace (its work is dropped, the new target untouched): the reason. diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index 2a4c484530..c9b1f17fa8 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -743,3 +743,32 @@ fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { assert!(revision_after_select >= revision && st.revision == revision_after_select, "nothing was edited after the switch"); assert!(app.distort.select_mask.is_none()); } + +/// The reason a stale workspace was closed is user-visible text: it is shown in the status bar and +/// returned to the control channel in the language in use, and every catalogue has it (#8896). +#[test] +fn the_reason_a_stale_workspace_closed_is_translated() { + let document = "Select and Mask was closed: its document is no longer the active one"; + let layer = "Select and Mask was closed: another layer was selected"; + for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { + for (reason, english) in [("document", document), ("layer", layer)] { + assert!(crate::i18n::has(lang, english), "{}: no row for the {reason} reason", lang.code()); + let shown = crate::i18n::tr(lang, english); + let mut app = if reason == "document" { two_documents() } else { app_with_selection() }; + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + crate::i18n::with_language(lang, || { + match other { + Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))), + None => assert!(app.session.set_active(1)), + } + let err = commit(&mut app).expect_err(&format!("{} / {reason}: the stale OK is refused", lang.code())); + assert_eq!(err, shown, "{}: the error is translated", lang.code()); + assert_eq!(app.ui.status, shown, "{}: the status is translated", lang.code()); + }); + assert_ne!(shown, english, "{}: the {reason} reason reads differently from English", lang.code()); + } + } +} From 5295017732f841802504130959d1e8eb7004990f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 26 Oct 2026 10:52:23 +0530 Subject: [PATCH 16/28] Adapt to egui 0.37 renames (Id::unique, scope_id, Role, IdSalt, dropdown names) --- crates/ui-egui/src/select_mask_ui.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 353e57d275..9fce26f8cc 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -1465,7 +1465,7 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt let labels: Vec = Mode::ALL.iter().map(|m| format!("{} ({:?})", m.label(), m.key())).collect(); let options: Vec<(Mode, &str)> = Mode::ALL.iter().zip(&labels).map(|(m, l)| (*m, l.as_str())).collect(); // The labels carry the view's letter, already translated. - widgets::dropdown_literal(ui, "sm-view-mode", &mut w.opts.view, &options, 112.0); + widgets::dropdown_literal(ui, "sm-view-mode", tl!("View"), &mut w.opts.view, &options, 112.0); }); // Beside the view list, as Photoshop's; long labels are cut rather than widening the // panel past its edge (every row below lines up on that edge). @@ -1492,7 +1492,14 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt widgets::color_edit_button_srgb(ui, &mut w.opts.overlay_color); ui.add_space(8.0); ui.label(tl!("Indicates")); - widgets::dropdown(ui, "sm-indicates", &mut w.opts.indicates_selected, &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], 120.0); + widgets::dropdown( + ui, + "sm-indicates", + tl!("Indicates"), + &mut w.opts.indicates_selected, + &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], + 120.0, + ); }); } }); @@ -1538,7 +1545,7 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt .filter(|(o, _)| !w.opts.decontaminate || !matches!(**o, "selection" | "layerMask")) .map(|(o, l)| (o.to_string(), l)) .collect(); - widgets::dropdown(ui, "sm-output-to", &mut w.opts.output, &options, 170.0); + widgets::dropdown(ui, "sm-output-to", tl!("Output To"), &mut w.opts.output, &options, 170.0); }); }); if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { @@ -1581,7 +1588,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut mods = egui::Modifiers::NONE; let mut edit: Option = None; let mut do_reset = false; - egui::Area::new(egui::Id::new("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(w) = app.distort.select_mask.as_mut() else { return }; let before = (w.opts.clone(), w.sample_all); w.texture_limit = ctx.input(|i| i.max_texture_side); @@ -1669,7 +1676,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if w.zoom <= 0.0 { w.zoom = ((area.width() - 40.0) / cw.max(1.0)).min((area.height() - 40.0) / chh.max(1.0)).clamp(0.01, 4.0); } - let resp = ui.interact(area, egui::Id::new("select-mask-view"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("select-mask-view"), Sense::click_and_drag()); // Over the image the keys are the workspace's: a panel button clicked last keeps the focus // otherwise, and Space (held to pan) would press it again. A number being typed keeps it. if resp.hovered() From 57fa742f5d9fe4fc014d3636f016dea18ceca050 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 26 Oct 2026 11:02:16 +0530 Subject: [PATCH 17/28] Save for Web WebP test: the Quality label now names a slider as well --- crates/ui-egui/src/file_ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 07414a5512..5b0c4685cf 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -2058,11 +2058,11 @@ mod web_playback_tests { fields.insert("format".into(), json!("webp")); assert_eq!(fields["lossless"], false); let mut h = harness(app, fields); - assert!(h.query_by_label("Quality").is_some()); + assert!(h.query_all_by_label("Quality").next().is_some()); h.get_by_label("Lossless").click(); h.run_steps(2); assert_eq!(h.state().1["lossless"], true); - assert!(h.query_by_label("Quality").is_none(), "lossless has no quality"); + assert!(h.query_all_by_label("Quality").next().is_none(), "lossless has no quality"); let v = variants(&json!({"format": "webp", "quality": 80})); assert_eq!((v[1]["format"].as_str(), v[1]["quality"].as_f64(), v[2]["quality"].as_f64()), (Some("webp"), Some(40.0), Some(20.0))); From 37c21e1d7787b859b4800b0ad46337d2f6c120ff Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 27 Oct 2026 20:09:03 +0530 Subject: [PATCH 18/28] fix(transform): preserve action targets and clipped previews --- crates/engine/src/cmd/actions_cmds/tests.rs | 37 +++++++++++++++++++ .../src/cmd/layer_multi_cmds/selection.rs | 7 +++- .../src/tools/transform_tool/preview.rs | 1 + .../ui-egui/src/tools/transform_tool/tests.rs | 22 +++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index b7974784b6..640573be61 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -294,6 +294,43 @@ fn record_explicit_transform_keeps_one_replayable_step() { assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]); } +#[test] +fn recorded_single_layer_transform_replays_after_layer_ids_shift() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("source pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + + s.execute("actions.record", json!({"name": "Move target"})).unwrap(); + s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap(); + assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"})); + + // Add a layer before the target, shifting its id; playback must resolve the current + // selection rather than keep the source document's numeric layer id. + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap(); + let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(source, target); + s.edit("target pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap(); + let before = s.active().unwrap().history.past_len(); + let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]); +} + /// A Background with a red bar and a slanted ruler line, ready for Straighten. fn ruled_background(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap(); diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 6d7fce1c7f..453c1c3061 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -140,7 +140,12 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio return recorded_move(s, p, pre_selected); } if id == "edit.transform" { - return recorded_transform(s, p, pre_selected); + // Multi-layer transforms carry the set of ids separately. A single-layer transform + // uses the ordinary `layer` target, which should follow the sole selected layer when + // the action is replayed in a document with different ids. + if p.get("layers").is_some_and(Value::is_array) { + return recorded_transform(s, p, pre_selected); + } } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index 2eb96b91d7..9a7a241b4b 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -214,6 +214,7 @@ pub(super) fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) } match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => { + l.clipped = false; // This ancestor only clips the covering children. Its background is already // on the base canvas; drawing it again would cover the moving layer in white. if let Some(board) = &mut g.artboard { diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 2afcd48f9c..e16f81f909 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -2110,6 +2110,28 @@ fn nested_group_preview_detaches_every_clipped_ancestor() { 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 several_preview_detaches_clipped_ancestors() { + 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 first = doc.layers.pop().unwrap(); + let first_id = first.id; + let mut second = Layer::raster("second", PixelFormat::RGBA8); + second.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0., 1., 0., 1.]); + let second_id = second.id; + let mut group = Layer::group("clipped", vec![first, second]); + group.clipped = true; + doc.layers.push(Layer::raster("hidden base", PixelFormat::RGBA8)); + doc.layers.push(group); + let shown = Several::shown(doc, &[first_id, second_id], &[]); + let (image, _) = composite_image(&shown, photocraft_geom::Rect::new(0, 0, 64, 64), 4096); + assert_eq!(image.pixels[16 * image.size[0] + 16], Color32::from_rgb(255, 0, 0)); + assert_eq!(image.pixels[16 * image.size[0] + 40], Color32::from_rgb(0, 255, 0)); +} + #[test] fn unknown_preview_target_keeps_the_previous_document_fallback() { let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); From d53e14bcccbdc0bc62e66fc745c827650bac38de Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 27 Oct 2026 20:10:24 +0530 Subject: [PATCH 19/28] fix(transform): limit ancestor detachment to moving preview --- crates/ui-egui/src/tools/transform_tool.rs | 7 +++++++ crates/ui-egui/src/tools/transform_tool/preview.rs | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index b5ec93cbaa..5bdb4d1bd6 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -170,6 +170,13 @@ impl Several { let mut d = live.clone(); show_only(&mut d.layers, ids); for id in ids { + if let Some(path) = d.path_of(*id) { + for n in 1..path.len() { + if let Some(ancestor) = path.get(..n).and_then(|p| d.layer_at_mut(p)) { + ancestor.clipped = false; + } + } + } if let Some(l) = d.layer_mut(*id) { l.clipped = false; } diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index 9a7a241b4b..2eb96b91d7 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -214,7 +214,6 @@ pub(super) fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) } match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => { - l.clipped = false; // This ancestor only clips the covering children. Its background is already // on the base canvas; drawing it again would cover the moving layer in white. if let Some(board) = &mut g.artboard { From a2364b20d0c9e29a76c2ceea2a94015816b69296 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 09:25:49 +0530 Subject: [PATCH 20/28] Fix imports lost when transform_tool was split into a module tree --- crates/ui-egui/src/tools/transform_tool.rs | 1 - crates/ui-egui/src/tools/transform_tool/begin.rs | 4 ++-- crates/ui-egui/src/tools/transform_tool/commit.rs | 5 +++-- crates/ui-egui/src/tools/transform_tool/drag_modes.rs | 3 ++- crates/ui-egui/src/tools/transform_tool/pointer.rs | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index ce857ac8c7..3845437e80 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -32,7 +32,6 @@ mod preview; mod warp; pub(crate) use begin::begin_content_aware; -use begin::moves_kind; pub(crate) use begin::moving_layers; #[cfg(test)] pub(crate) use begin::moving_texel; diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index 303d07c9b8..ba49e27042 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,7 +1,7 @@ -use super::commit::{commit, start_steps}; +use super::commit::{cancel, commit, start_steps}; use super::preview::{Above, preview_image}; use super::{ - Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Steps, + Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, TransformPreview, TransformSession, Warp, WarpStyle, corners, json, }; diff --git a/crates/ui-egui/src/tools/transform_tool/commit.rs b/crates/ui-egui/src/tools/transform_tool/commit.rs index 5b7c4fcc5a..8a26d852f2 100644 --- a/crates/ui-egui/src/tools/transform_tool/commit.rs +++ b/crates/ui-egui/src/tools/transform_tool/commit.rs @@ -1,7 +1,8 @@ -use super::begin::{fold_made, rect_to_quad_affine, take_back_made, warps_type}; +use super::begin::{fold_made, moves_kind, moving_layers, rect_to_quad_affine, take_back_made, warps_type}; use super::options_bar::{rotate_about_pivot, scale_about_pivot}; use super::{ - Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Step, Steps, TransformPreview, TransformSession, corners, json, + Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Step, Steps, TransformPreview, TransformSession, + corners, json, }; /// Undo and Redo while the commit runs in the background (Free Transform, or Warp). diff --git a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs index 477cd51ac5..5b416845de 100644 --- a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs +++ b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs @@ -1,5 +1,6 @@ +use super::begin::moves_kind; use super::pointer::{Gesture, Hit, carry_pivot}; -use super::{Homography, LayerContent, LayerId, PhotocraftApp, TransformMode, TransformSession}; +use super::{Homography, LayerContent, PhotocraftApp, TransformMode, TransformSession}; /// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion /// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 6860229895..dd76e446c9 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -1,10 +1,10 @@ -use super::begin::warps_type; +use super::begin::{moving_layers, warps_type}; use super::commit::{commit, committing, moves_type}; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::{ - CursorIcon, HANDLE_PX, Homography, LayerContent, LayerId, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, - WarpStyle, handle_tolerance, + CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, WarpStyle, + handle_tolerance, }; /// Which part of the box a document point hits. From 4af5b3a669867d9378e49b44a5d2a4679688100e Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 09:26:13 +0530 Subject: [PATCH 21/28] Split Select and Mask's workspace closure into paint/cursor/input helpers (cognitive complexity) --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 288 ++++++++++--------- 1 file changed, 157 insertions(+), 131 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index c3a63bb7fa..90eaf3fc79 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -1570,6 +1570,160 @@ fn follow_revision(app: &mut PhotocraftApp) { } } +/// Draws the document, the refined edge, the stroke being drawn and the polygon in progress. +fn paint_canvas(ui: &mut egui::Ui, ctx: &egui::Context, w: &mut SelectMaskWorkspace, t: &Tokens, painter: &egui::Painter, resp: &egui::Response, area: ERect) { + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + widgets::checker(&clip, img.intersect(area), 8.0); + let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); + match (preview, &w.image_tex) { + (Some(tex), _) => { + // The reduced copy's last row and column may reach past the canvas. + let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); + } + (None, Some(tex)) => { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + (None, None) => {} + } + // Marching Ants: the refined selection's edge, black dashes on white. + if !w.hide_view + && w.opts.view == Mode::MarchingAnts + && let Some(edges) = &w.preview.ants + { + let phase = (ui.input(|i| i.time) * 8.0) as usize; + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + } + if w.preview.shown != w.preview.want && !w.pressed { + let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + // The stroke being drawn: the part the preview doesn't show yet (all of it without + // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. + let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); + if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { + let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); + match g.tool { + SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { + let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); + let r = w.size() as f32 * 0.5 * v.zoom; + for p in tail { + clip.circle_filled(*p, r, ink); + } + if tail.len() > 1 { + clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); + } + } + SmTool::Lasso => { + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + SmTool::Object => { + if let (Some(a), Some(b)) = (line.first(), line.last()) { + clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + } + _ => {} + } + } + if let Some((_, pts)) = &w.poly { + let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + if let Some(hp) = resp.hover_pos() { + line.push(hp); + } + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } +} + +/// The cursor over the document, and the image's outline. +fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::Painter, resp: &egui::Response, v: View, pan: bool, alt: bool) { + let (img, t) = (v.rect(w.canvas), Tokens::get(ctx)); + // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle + // (with + or −), the zoom glass, else a crosshair. + if let Some(hp) = resp.hover_pos() { + w.hover = Some(v.to_doc(hp)); + } + if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if let Some(hp) = resp.hover_pos() { + if pan { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } else if w.tool.brushes() { + let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + } else { + ctx.set_cursor_icon(match w.tool { + SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, + SmTool::Zoom => egui::CursorIcon::ZoomIn, + _ => egui::CursorIcon::Crosshair, + }); + } + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); +} + +/// Pan, zoom-click and tool gestures on the document, as events for `pointer`. +#[allow(clippy::too_many_arguments)] +fn canvas_input( + ui: &egui::Ui, + ctx: &egui::Context, + w: &mut SelectMaskWorkspace, + resp: &egui::Response, + v: View, + pan: bool, + events: &mut Vec, + mods: &mut egui::Modifiers, +) { + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if w.tool == SmTool::PolygonalLasso && !pan { + *mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + w.close_polygon(); + } else if resp.clicked() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + *mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + } +} + /// Draws the workspace (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { follow_revision(app); @@ -1698,140 +1852,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } let v = View { area, zoom: w.zoom, center: w.center }; - let clip = painter.with_clip_rect(area); - let img = v.rect(w.canvas); - let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - widgets::checker(&clip, img.intersect(area), 8.0); - let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); - match (preview, &w.image_tex) { - (Some(tex), _) => { - // The reduced copy's last row and column may reach past the canvas. - let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); - clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); - } - (None, Some(tex)) => { - clip.image(tex.id(), img, uv, Color32::WHITE); - } - (None, None) => {} - } - // Marching Ants: the refined selection's edge, black dashes on white. - if !w.hide_view - && w.opts.view == Mode::MarchingAnts - && let Some(edges) = &w.preview.ants - { - let phase = (ui.input(|i| i.time) * 8.0) as usize; - for e in edges { - let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); - let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); - clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); - } - ctx.request_repaint_after(std::time::Duration::from_millis(120)); - } - if w.preview.shown != w.preview.want && !w.pressed { - let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); - ui.put(sp, egui::Spinner::new().size(16.0)); - } - // The stroke being drawn: the part the preview doesn't show yet (all of it without - // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. - let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); - if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { - let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); - match g.tool { - SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { - let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); - let r = w.size() as f32 * 0.5 * v.zoom; - for p in tail { - clip.circle_filled(*p, r, ink); - } - if tail.len() > 1 { - clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); - } - } - SmTool::Lasso => { - clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); - } - SmTool::Object => { - if let (Some(a), Some(b)) = (line.first(), line.last()) { - clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); - } - } - _ => {} - } - } - if let Some((_, pts)) = &w.poly { - let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); - if let Some(hp) = resp.hover_pos() { - line.push(hp); - } - clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); - } + paint_canvas(ui, ctx, w, &t, &painter, &resp, area); // Space, the middle button or the Hand pan. The Polygonal Lasso takes clicks: each adds a // vertex, a double-click closes. let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == SmTool::Hand; - // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle - // (with + or −), the zoom glass, else a crosshair. - if let Some(hp) = resp.hover_pos() { - w.hover = Some(v.to_doc(hp)); - } - let alt = ui.input(|i| i.modifiers.alt); - if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - } else if let Some(hp) = resp.hover_pos() { - if pan { - ctx.set_cursor_icon(egui::CursorIcon::Grab); - } else if w.tool.brushes() { - let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); - clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); - clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); - let add = w.add != alt; - for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { - clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); - if add { - clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); - } - } - ctx.set_cursor_icon(egui::CursorIcon::None); - } else { - ctx.set_cursor_icon(match w.tool { - SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, - SmTool::Zoom => egui::CursorIcon::ZoomIn, - _ => egui::CursorIcon::Crosshair, - }); - } - } - clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - if pan && resp.dragged() { - let dl = resp.drag_delta(); - w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; - } else if w.tool == SmTool::PolygonalLasso && !pan { - mods = ui.input(|i| i.modifiers); - if resp.double_clicked() { - w.close_polygon(); - } else if resp.clicked() - && let Some(pp) = resp.interact_pointer_pos() - { - let q = v.to_doc(pp); - events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); - } - } else if !pan - && resp.is_pointer_button_down_on() - && let Some(pp) = resp.interact_pointer_pos() - { - let q = v.to_doc(pp); - mods = ui.input(|i| i.modifiers); - if w.pressed { - events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); - ctx.request_repaint(); - } else { - w.pressed = true; - events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); - } - } - if w.pressed && !resp.is_pointer_button_down_on() { - w.pressed = false; - let q = w.hover.unwrap_or(w.center); - events.push(ToolEvent::Up { x: q[0], y: q[1] }); - } + paint_cursor(ctx, w, &painter.with_clip_rect(area), &resp, v, pan, ui.input(|i| i.modifiers.alt)); + canvas_input(ui, ctx, w, &resp, v, pan, &mut events, &mut mods); if (w.opts.clone(), w.sample_all) != before { w.stale(); } From 4e7dc407f81580ea09e308884c63c89629b96b17 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 15:58:41 +0530 Subject: [PATCH 22/28] Import texture_quad where transform_tool/begin.rs uses it --- crates/ui-egui/src/tools/transform_tool.rs | 2 +- crates/ui-egui/src/tools/transform_tool/begin.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index 3845437e80..1a2b741bc2 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -51,7 +51,7 @@ pub(crate) use draw::{Readout, live_readout, styled_frame}; pub(crate) use draw::{carries_selection_of, draw_selection}; pub use draw::{draw_content, draw_overlay}; #[cfg(test)] -use draw::{front_vertices, handle_anchor, texture_quad}; +use draw::{front_vertices, handle_anchor}; pub(crate) use options_bar::position_size_fields; #[cfg(test)] use options_bar::{ diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index ba49e27042..f3da463d2f 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,4 +1,6 @@ use super::commit::{cancel, commit, start_steps}; +#[cfg(test)] +use super::draw::texture_quad; use super::preview::{Above, preview_image}; use super::{ Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, From 73bef48025e04ab448d091fbe152051dc7229774 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:13:52 +0530 Subject: [PATCH 23/28] Fix Select and Mask modal routing and preview memory --- crates/ui-egui/src/control/tests.rs | 3 +- crates/ui-egui/src/dialogs/distort_ui.rs | 4 +- crates/ui-egui/src/dialogs/select_mask_ui.rs | 39 +++++++++++++++++-- .../src/dialogs/select_mask_ui/tests.rs | 20 ++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index c7b9896e2f..be8535367b 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -134,7 +134,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { app.run("layer.new.layer", json!({})).unwrap(); app.run("edit.fill", json!({"color":"#808080"})).unwrap(); app.run("select.rect", json!({"x":8,"y":8,"width":8,"height":8})).unwrap(); - for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle"] { + for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle", "select.selectAndMask"] { let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":workspace})); assert_eq!(opened["ok"], true, "{workspace}: {opened}"); assert_eq!(crate::distort_ui::modal_workspace(&app), Some(workspace)); @@ -144,6 +144,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { json!({"id":"select.all"}), json!({"id":"view.zoomIn"}), json!({"id":"filter.cameraRaw"}), + json!({"id":"filter.liquify"}), json!({"id":workspace,"params":{"ui":null}}), ] { let blocked = call(&mut app, &ctx, "ui.menu.invoke", request.clone()); diff --git a/crates/ui-egui/src/dialogs/distort_ui.rs b/crates/ui-egui/src/dialogs/distort_ui.rs index 5b97971519..b5ee13f464 100644 --- a/crates/ui-egui/src/dialogs/distort_ui.rs +++ b/crates/ui-egui/src/dialogs/distort_ui.rs @@ -103,7 +103,9 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm /// Adaptive Wide Angle). These are modal: other menu commands wait for OK or Cancel /// (`menus::modal_allows`). pub(crate) fn modal_workspace(app: &PhotocraftApp) -> Option<&'static str> { - if app.distort.liquify.is_some() { + if app.distort.select_mask.is_some() { + Some("select.selectAndMask") + } else if app.distort.liquify.is_some() { Some("filter.liquify") } else if app.distort.caf.is_some() { Some("edit.contentAwareFill") diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 90eaf3fc79..98e536adee 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -48,6 +48,8 @@ const PANEL_W: f32 = 350.0; const UNDO_LIMIT: usize = 100; /// Marching ants with more edges than this are not drawn. const MAX_ANT_EDGES: usize = 200_000; +/// Bound the temporary dense mask used for marching ants. +const MAX_ANT_PIXELS: usize = 4_000_000; /// The `prefs.dialogs` key under which Remember Settings keeps the settings. const REMEMBERED: &str = "select.refineEdge.workspace"; const CMD: &str = crate::select_mask_view::COMMAND; @@ -1103,6 +1105,38 @@ fn ant_edges(m: &[f32], w: usize, h: usize, k: f32) -> Option Some(out) } +/// Count selected pixels without allocating a canvas-sized mask. Missing tiles use the surface's +/// default value, including an inverted selection whose default is selected. +fn selected_pixels(selection: Option<&Surface>, area: Rect) -> usize { + let Some(surface) = selection else { return 0 }; + let default_selected = surface.default_pixel().first().is_some_and(|v| *v >= 0.5); + let area_pixels = (area.width() as usize).saturating_mul(area.height() as usize); + let mut count = if default_selected { area_pixels } else { 0 }; + for (coord, _) in surface.tiles() { + let tile_area = coord.rect().intersect(&area); + if tile_area.is_empty() { + continue; + } + let pixels = (tile_area.width() as usize).saturating_mul(tile_area.height() as usize); + if default_selected { + count = count.saturating_sub(pixels); + } + let values = photocraft_algo::selection::mask_from_surface(Some(surface), tile_area); + count = count.saturating_add(values.iter().filter(|v| **v >= 0.5).count()); + } + count +} + +/// Generate ants only when the dense temporary mask fits the preview budget. +fn preview_ants(selection: Option<&Surface>, area: Rect, k: f32) -> Option> { + let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > MAX_ANT_PIXELS) { + return None; + } + let coverage = photocraft_algo::selection::mask_from_surface(selection, area); + ant_edges(&coverage, w, h, k) +} + /// The preview of `job`: the edits applied (from where the cache left off), the edge refined and /// the result shown in the view, as an image no wider than the device allows. fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { @@ -1150,10 +1184,9 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result let (tw, th) = (pw.div_ceil(f.max(1)), ph.div_ceil(f.max(1))); let buf = photocraft_compose::render_reduced(&shown, u32::try_from(tw).unwrap_or(1), u32::try_from(th).unwrap_or(1)); let image = crate::canvas::display_image(job.display.as_deref(), &buf); - let coverage = photocraft_algo::selection::mask_from_surface(shown.selection.as_ref(), shown.bounds()); - let selected = coverage.iter().filter(|v| **v >= 0.5).count(); + let selected = selected_pixels(shown.selection.as_ref(), shown.bounds()); let mode = params.get("__view").and_then(|v| v.get("mode")).and_then(Value::as_str); - let ants = (mode == Some(Mode::MarchingAnts.id())).then(|| ant_edges(&coverage, pw, ph, k as f32)).flatten(); + let ants = if mode == Some(Mode::MarchingAnts.id()) { preview_ants(shown.selection.as_ref(), shown.bounds(), k as f32) } else { None }; let covers = [(pw as f32) * k as f32, (ph as f32) * k as f32]; Ok((Shown { image, ants, selected, trail: job.trail, covers }, cache, proxy)) } diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index c9b1f17fa8..0a67478d5e 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -772,3 +772,23 @@ fn the_reason_a_stale_workspace_closed_is_translated() { } } } + +#[test] +fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { + let area = Rect::new(0, 0, 100_000, 100_000); + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(99_999, 99_999, &[1.0]); + selection.write_pixel(2, 3, &[0.5]); + selection.write_pixel(-1, -1, &[1.0]); + assert_eq!(selected_pixels(Some(&selection), area), 2); + assert!(preview_ants(Some(&selection), area, 1.0).is_none()); + let small = Rect::new(0, 0, 10, 10); + assert_eq!(selected_pixels(Some(&selection), small), 1); + assert_eq!(preview_ants(Some(&selection), small, 1.0).unwrap().len(), 4); + + let mut inverted = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + inverted.write_pixel(2, 3, &[0.0]); + let smaller = Rect::new(0, 0, 1000, 1000); + assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999); + assert_eq!(selected_pixels(None, area), 0); +} From 19cc32cb22ff62ed151533cef6e476a6bfa80b06 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:17:42 +0530 Subject: [PATCH 24/28] Preserve marching ants for large sparse selections --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 53 ++++++++++++++++++- .../src/dialogs/select_mask_ui/tests.rs | 2 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 98e536adee..3430569775 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -1131,12 +1131,63 @@ fn selected_pixels(selection: Option<&Surface>, area: Rect) -> usize { fn preview_ants(selection: Option<&Surface>, area: Rect, k: f32) -> Option> { let (w, h) = (area.width() as usize, area.height() as usize); if w.checked_mul(h).is_none_or(|n| n > MAX_ANT_PIXELS) { - return None; + return sparse_preview_ants(selection?, area, k); } let coverage = photocraft_algo::selection::mask_from_surface(selection, area); ant_edges(&coverage, w, h, k) } +/// Follow sparse selections on large canvases without scanning the empty canvas. A selected +/// default or too many stored pixels falls back to omitting ants, as for too many edges. +fn sparse_preview_ants(surface: &Surface, area: Rect, k: f32) -> Option> { + if surface.default_pixel().first().is_some_and(|v| *v >= 0.5) { + return None; + } + let mut work = 0usize; + let mut edges = Vec::new(); + let mut sample = vec![0.0; surface.channels()]; + for (coord, _) in surface.tiles() { + let tile = coord.rect().intersect(&area); + if tile.is_empty() { + continue; + } + work = work.checked_add((tile.width() as usize).checked_mul(tile.height() as usize)?)?; + if work > MAX_ANT_PIXELS { + return None; + } + let coverage = photocraft_algo::selection::mask_from_surface(Some(surface), tile); + for y in tile.y0..tile.y1 { + for x in tile.x0..tile.x1 { + let index = (y - tile.y0) as usize * tile.width() as usize + (x - tile.x0) as usize; + if !coverage.get(index).is_some_and(|v| *v >= 0.5) { + continue; + } + let (fx, fy) = ((x - area.x0) as f32, (y - area.y0) as f32); + for (nx, ny, a, b) in [ + (x - 1, y, [fx, fy], [fx, fy + 1.0]), + (x + 1, y, [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (x, y - 1, [fx, fy], [fx + 1.0, fy]), + (x, y + 1, [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + let selected = if nx < area.x0 || nx >= area.x1 || ny < area.y0 || ny >= area.y1 { + false + } else { + surface.read_pixel(nx, ny, &mut sample); + sample.first().is_some_and(|v| *v >= 0.5) + }; + if !selected { + edges.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + if edges.len() > MAX_ANT_EDGES { + return None; + } + } + } + } + } + } + Some(edges) +} + /// The preview of `job`: the edits applied (from where the cache left off), the edge refined and /// the result shown in the view, as an image no wider than the device allows. fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 0a67478d5e..8943e178b3 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -781,7 +781,7 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { selection.write_pixel(2, 3, &[0.5]); selection.write_pixel(-1, -1, &[1.0]); assert_eq!(selected_pixels(Some(&selection), area), 2); - assert!(preview_ants(Some(&selection), area, 1.0).is_none()); + assert_eq!(preview_ants(Some(&selection), area, 1.0).unwrap().len(), 8); let small = Rect::new(0, 0, 10, 10); assert_eq!(selected_pixels(Some(&selection), small), 1); assert_eq!(preview_ants(Some(&selection), small, 1.0).unwrap().len(), 4); From 19fa473039ef11deda7c3200c8fac1de41309086 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:41:37 +0530 Subject: [PATCH 25/28] Keep stale workspace test on programmatic document switch --- crates/ui-egui/src/dialogs/select_mask_ui/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 8943e178b3..7df542fe74 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -691,7 +691,7 @@ fn ok_after_a_document_switch_changes_neither_document() { open(&mut app).unwrap(); ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); let before = (fingerprint(&app, 0), fingerprint(&app, 1)); - let switched = call(&mut app, "ui.menu.invoke", json!({"command": "window.document.1"})); + let switched = call(&mut app, "engine.execute", json!({"command": "document.activate", "params": {"document": 1}})); assert_eq!(app.session.active_index(), Some(1), "{switched}"); let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); From 6c6cfc243a1ca8942e40049e00027f0f8331d5b3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:40:12 +0530 Subject: [PATCH 26/28] perf(stack): cache bounded entropy terms across pixels (#9622) * perf(stack): cache bounded entropy terms across pixels * test(stack): iterate entropy terms directly for Clippy * ci: rerun checks --- crates/algo/examples/bench_stack_entropy.rs | 104 ++++++++++++++++++++ crates/algo/src/stack.rs | 65 ++++++++++-- crates/algo/src/stack/tests.rs | 96 ++++++++++++++++++ 3 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 crates/algo/examples/bench_stack_entropy.rs diff --git a/crates/algo/examples/bench_stack_entropy.rs b/crates/algo/examples/bench_stack_entropy.rs new file mode 100644 index 0000000000..b496b69d3f --- /dev/null +++ b/crates/algo/examples/bench_stack_entropy.rs @@ -0,0 +1,104 @@ +//! Stack Mode Entropy timings and exact output hashes, including lookup preparation. +//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example +//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`. +//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is +//! deliberately small. Input generation, output hashing and deallocation are outside timings. + +use std::path::PathBuf; +use std::time::Instant; + +use photocraft_algo::stack::{Stat, combine}; +use photocraft_testkit::perf::{self, Summary}; +use serde_json::json; + +struct Options { + quick: bool, + reps: usize, + threads: Option, + json: Option, +} + +fn options() -> Result { + let mut o = Options { quick: false, reps: 5, threads: None, json: None }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--quick" => o.quick = true, + "--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?, + "--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?), + "--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()), + _ => return Err(format!("unknown option: {arg}")), + } + } + if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) { + return Err("expected 1..=25 repetitions and 1..=64 threads".into()); + } + Ok(o) +} + +fn benchmark(o: &Options) -> Result<(), String> { + let mut rows = Vec::new(); + let cases = [ + ("3 layers opaque 24 MP", 3, 24_000_000, false), + ("7 layers varying alpha 24 MP", 7, 24_000_000, true), + ("3 layers opaque 36 MP", 3, 36_000_000, false), + ("257 layers varying alpha", 257, 8192, true), + ]; + for (name, count, pixels, masked) in cases { + let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels }; + let layers: Vec> = (0..count) + .map(|k| { + (0..pixels) + .map(|i| { + [ + ((i * 31 + k * 17) % 256) as f32 / 255.0, + ((i / 7 + k * 29) % 32) as f32 / 31.0, + ((i + k * 13) % 1024) as f32 / 255.0 - 0.5, + if masked && k > i % (count + 1) { 0.0 } else { 1.0 }, + ] + }) + .collect() + }) + .collect(); + // Warm the worker pool and input pages before collecting samples. + std::hint::black_box(combine(&layers, Stat::Entropy)); + let mut samples = Vec::with_capacity(o.reps); + let mut hash = 0u64; + for rep in 0..o.reps { + let start = Instant::now(); + let out = std::hint::black_box(combine(&layers, Stat::Entropy)); + samples.push(start.elapsed().as_secs_f64() * 1000.0); + if rep == 0 { + hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3)); + } + } + let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50); + println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}"); + let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None); + row["output_hash"] = json!(format!("{hash:016x}")); + row["pixels"] = json!(pixels); + rows.push(row); + } + if let Some(path) = &o.json { + let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None); + std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(()) +} + +fn run() -> Result<(), String> { + let o = options()?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(threads) = o.threads { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?; + return pool.install(|| benchmark(&o)); + } + benchmark(&o) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 482e8df9a7..52e67a3b71 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -37,8 +37,54 @@ impl EntropyScratch { } } +/// One shared table per combine, never per pixel or worker. Rows cover the contributing sample +/// count, which can change at every pixel because of transparency or unequal layer lengths. +/// The triangular layout stores log2(n) followed by -p*log2(p) for counts 1..=n. Keeping the +/// division after the sum (rather than normalising each term) preserves the old output bits. +struct EntropyTerms { + values: Vec, +} + +impl EntropyTerms { + // At most 130 KiB, independent of image size and total layer count. Deeper stacks retain + // the original calculation; pixels with fewer contributors can still use a cached row. + const MAX_SAMPLES: usize = 256; + + fn new(samples: usize, pixels: usize) -> Self { + let mut values = Vec::new(); + // Tiny images do not amortise preparation, especially with hundreds of layers. + if samples < 2 || pixels < 256 { + return Self { values }; + } + let max = samples.min(Self::MAX_SAMPLES); + // max is capped at 256 before any size arithmetic or allocation. + let len = (max + 1) * (max + 2) / 2; + if values.try_reserve_exact(len).is_err() { + return Self { values }; + } + values.extend_from_slice(&[0.0; 3]); // Unused rows for zero and one sample. + for samples in 2..=max { + let n = samples as f32; + values.push(n.log2()); + for count in 1..=samples { + let p = count as f32 / n; + values.push(-p * p.log2()); + } + } + Self { values } + } + + fn row(&self, samples: usize) -> Option<&[f32]> { + if !(2..=Self::MAX_SAMPLES).contains(&samples) { + return None; + } + let start = samples * (samples + 1) / 2; + self.values.get(start..start + samples + 1) + } +} + /// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`. -fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { +fn entropy(v: &[f32], scratch: &mut EntropyScratch, terms: Option<&[f32]>) -> f32 { let n = v.len() as f32; for x in v { let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8; @@ -57,8 +103,11 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { .touched .iter() .map(|&bin| { - let p = scratch.counts[usize::from(bin)] as f32 / n; - -p * p.log2() + let count = scratch.counts[usize::from(bin)]; + terms.and_then(|row| row.get(count as usize)).copied().unwrap_or_else(|| { + let p = count as f32 / n; + -p * p.log2() + }) }) .sum(); // Reset only the bins used, so the 256-entry array is never cleared wholesale. @@ -66,7 +115,7 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { scratch.counts[usize::from(bin)] = 0; } scratch.touched.clear(); - h / n.log2() + h / terms.and_then(|row| row.first()).copied().unwrap_or_else(|| n.log2()) } /// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the @@ -115,7 +164,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { if v.len() < 2 { return 0.0; } - return entropy(v, scratch); + return entropy(v, scratch, None); } _ => {} } @@ -153,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's // instructions by a fifth. match s { - Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Entropy => { + let pixels = layers.iter().map(Vec::len).max().unwrap_or(0); + let terms = EntropyTerms::new(layers.len(), pixels); + combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) }) + } Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), diff --git a/crates/algo/src/stack/tests.rs b/crates/algo/src/stack/tests.rs index 3708ceb40d..d9f041124d 100644 --- a/crates/algo/src/stack/tests.rs +++ b/crates/algo/src/stack/tests.rs @@ -323,6 +323,102 @@ fn entropy_is_deterministic() { } } +/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately +/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too. +fn entropy_reference(v: &[f32]) -> f32 { + if v.len() < 2 { + return 0.0; + } + let mut counts = [0u32; 256]; + for x in v { + counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1; + } + let n = v.len() as f32; + let h: f32 = counts + .iter() + .filter(|&&count| count > 0) + .map(|&count| { + let p = count as f32 / n; + -p * p.log2() + }) + .sum(); + h / n.log2() +} + +#[test] +fn entropy_terms_preserve_each_operation_and_bound_storage() { + let terms = EntropyTerms::new(usize::MAX, usize::MAX); + let max = EntropyTerms::MAX_SAMPLES; + assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2); + assert!(terms.values.len() * size_of::() < 130 * 1024); + for samples in 2..=max { + let row = terms.row(samples).unwrap(); + let n = samples as f32; + assert_eq!(row[0].to_bits(), n.log2().to_bits()); + for (count, &term) in row.iter().enumerate().skip(1) { + let p = count as f32 / n; + assert_eq!(term.to_bits(), (-p * p.log2()).to_bits(), "{count}/{samples}"); + } + } + for samples in [0, 1, max + 1, usize::MAX] { + assert!(terms.row(samples).is_none()); + } + assert!(EntropyTerms::new(1, usize::MAX).values.is_empty()); + assert!(EntropyTerms::new(usize::MAX, 255).values.is_empty()); + assert!(EntropyTerms { values: Vec::new() }.row(2).is_none()); +} + +#[test] +fn entropy_lookup_and_fallback_match_reference_bits() { + let terms = EntropyTerms::new(EntropyTerms::MAX_SAMPLES, CHUNK); + let mut scratch = EntropyScratch::new(); + let special = [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, -3.0, -0.0, 0.0, 0.5 / 255.0, 0.5, 1.0, 4.0]; + // Move repeatedly between counts and distributions to exercise scratch reset, duplicate + // bins, every cached denominator and the uncached side of the table boundary. + for samples in (2..=EntropyTerms::MAX_SAMPLES + 2).chain([513, 3, 256, 2]) { + for pattern in 0..4 { + let v: Vec = (0..samples) + .map(|i| match pattern { + 0 => 0.5, + 1 => (i % 256) as f32 / 255.0, + 2 => ((i * 137 + samples * 29) % 65536) as f32 / 65535.0, + _ => special[i % special.len()], + }) + .collect(); + let expected = entropy_reference(&v).to_bits(); + assert_eq!(entropy(&v, &mut scratch, terms.row(samples)).to_bits(), expected, "cached: n={samples}, pattern={pattern}"); + assert_eq!(entropy(&v, &mut scratch, None).to_bits(), expected, "fallback: n={samples}, pattern={pattern}"); + } + } +} + +#[test] +fn entropy_combine_matches_reference_with_changing_contributor_counts() { + for (count, len) in [(19, 2 * CHUNK + 17), (EntropyTerms::MAX_SAMPLES + 1, 513)] { + let layers: Vec> = (0..count) + .map(|k| { + (0..len.saturating_sub(k % 13)) + .map(|i| { + [ + ((i * 31 + k * 7) % 256) as f32 / 255.0, + ((i * 17 + k) % 65536) as f32 / 65535.0, + if (i + k).is_multiple_of(23) { f32::NAN } else { (i % 7) as f32 - 2.0 }, + if k > i % (count + 1) { 0.0 } else { 0.5 }, + ] + }) + .collect() + }) + .collect(); + let expected = bits(&combine_with(&layers, |v, _| entropy_reference(v))); + assert_eq!(bits(&combine(&layers, Stat::Entropy)), expected); + #[cfg(not(target_arch = "wasm32"))] + for threads in [1, 3] { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + assert_eq!(pool.install(|| bits(&combine(&layers, Stat::Entropy))), expected, "{threads} threads"); + } + } +} + /// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal /// length) gives, pixel by pixel, what combining that pixel on its own gives. #[test] From 539f5bd58f1ee17f15a02c518d834e1776ccc718 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:41:22 +0530 Subject: [PATCH 27/28] perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) (#9651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) Refs #5426. Follow-up to #5499. Cause: While #5499 parallelized local_blur and the per-dab mix in DabTool::Focus, local_sharpen in crates/algo/src/retouch.rs remained single-threaded. In addition, protect_detail redundantly evaluated coordinate clamping and strided memory lookups across 8 neighbors inside the per-channel inner loop for every pixel in the dab. On large brush sizes (e.g. 1000–2500 px), strokes took seconds and stalled the application. Change: - Process local_sharpen window rows in parallel with Rayon across threads, with wasm32 serial fallback matching local_blur. - Hoist protect_detail 3x3 neighborhood offset calculations outside the channel loop. - Fast-path interior pixels (x > 0 && x + 1 < w && y > 0 && y + 1 < h) with direct stride offsets, skipping boundary clamping entirely for >99.9% of pixels in large brush dabs. - Add retouch_sharpen_exact integration test verifying bit-for-bit parity against the frozen scalar implementation across layouts, edges, and channels. * bench(engine): add bench_sharpen_tool example --- crates/algo/src/retouch.rs | 66 ++++++++++++--- crates/algo/tests/it/main.rs | 1 + crates/algo/tests/it/retouch_sharpen_exact.rs | 82 +++++++++++++++++++ crates/engine/examples/bench_sharpen_tool.rs | 62 ++++++++++++++ 4 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 crates/algo/tests/it/retouch_sharpen_exact.rs create mode 100644 crates/engine/examples/bench_sharpen_tool.rs diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 00388fe399..78b588f787 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -210,34 +210,80 @@ pub fn local_sharpen( protect_detail: bool, ) -> Vec { let (x0, y0, x1, y1) = win; - let ow = x1 - x0; + let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } let blur = local_blur(data, w, h, ch, alpha, win, 1.0); let mut out = vec![0.0f32; blur.len()]; - for y in y0..y1 { - for x in x0..x1 { - let o = ((y - y0) * ow + (x - x0)) * ch; + let stride = ow * ch; + + // Rows are independent, so process them in parallel with rayon (#5426). + let process_row = |(oy, dst): (usize, &mut [f32])| { + let y = y0 + oy; + let blur_row = &blur[oy * stride..(oy + 1) * stride]; + for ox in 0..ow { + let x = x0 + ox; + let o = ox * ch; let i = (y * w + x) * ch; + + // Precompute 3×3 neighbourhood offsets once per pixel if protecting detail. + let offsets: [usize; 8] = if protect_detail { + if x > 0 && x + 1 < w && y > 0 && y + 1 < h { + let w_ch = w * ch; + [i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch] + } else { + let get_n_idx = |dx: i32, dy: i32| -> usize { + let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize; + let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize; + (ny * w + nx) * ch + }; + [ + get_n_idx(-1, -1), + get_n_idx(0, -1), + get_n_idx(1, -1), + get_n_idx(-1, 0), + get_n_idx(1, 0), + get_n_idx(-1, 1), + get_n_idx(0, 1), + get_n_idx(1, 1), + ] + } + } else { + [0; 8] + }; + for c in 0..ch { let v = data[i + c]; if Some(c) == alpha { - out[o + c] = v; + dst[o + c] = v; continue; } - let mut s = v + amount * (v - blur[o + c]); + let mut s = v + amount * (v - blur_row[o + c]); if protect_detail { let (mut lo, mut hi) = (v, v); - for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); - let nv = data[(ny * w + nx) * ch + c]; + for &n_idx in &offsets { + let nv = data[n_idx + c]; lo = lo.min(nv); hi = hi.max(nv); } s = s.clamp(lo, hi); } - out[o + c] = s; + dst[o + c] = s; } } + }; + + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(stride).enumerate().for_each(process_row); } + #[cfg(target_arch = "wasm32")] + { + out.chunks_mut(stride).enumerate().for_each(process_row); + } + out } diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index e611f93cb9..1f26e46b8e 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -3,4 +3,5 @@ mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; +mod retouch_sharpen_exact; mod warp_clipped; diff --git a/crates/algo/tests/it/retouch_sharpen_exact.rs b/crates/algo/tests/it/retouch_sharpen_exact.rs new file mode 100644 index 0000000000..9e75a261ab --- /dev/null +++ b/crates/algo/tests/it/retouch_sharpen_exact.rs @@ -0,0 +1,82 @@ +//! Frozen scalar sharpen from main, used as an exact-output oracle. +use photocraft_algo::retouch::{local_blur, local_sharpen}; + +#[test] +fn matches_scalar_sharpen_bit_for_bit_across_layouts_edges_and_hdr() { + for (w, h) in [(1, 1), (3, 7), (37, 29)] { + for ch in 1..=5 { + for alpha in std::iter::once(None).chain((0..ch).map(Some)) { + let data: Vec = (0..w * h * ch) + .map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 }) + .collect(); + for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] { + for amount in [0.0, 0.5, 1.0, 2.5] { + for protect_detail in [false, true] { + let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + assert_eq!(actual.len(), expected.len()); + for (i, (a, b)) in actual.iter().zip(&expected).enumerate() { + assert_eq!( + a.to_bits(), + b.to_bits(), + "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} amount={amount} protect={protect_detail} sample={i}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn empty_channel_layout_remains_empty() { + assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty()); +} + +#[allow(clippy::too_many_arguments)] +fn scalar_local_sharpen( + data: &[f32], + w: usize, + h: usize, + ch: usize, + alpha: Option, + win: (usize, usize, usize, usize), + amount: f32, + protect_detail: bool, +) -> Vec { + let (x0, y0, x1, y1) = win; + let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } + let blur = local_blur(data, w, h, ch, alpha, win, 1.0); + let mut out = vec![0.0f32; blur.len()]; + for y in y0..y1 { + for x in x0..x1 { + let o = ((y - y0) * ow + (x - x0)) * ch; + let i = (y * w + x) * ch; + for c in 0..ch { + let v = data[i + c]; + if Some(c) == alpha { + out[o + c] = v; + continue; + } + let mut s = v + amount * (v - blur[o + c]); + if protect_detail { + let (mut lo, mut hi) = (v, v); + for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); + let nv = data[(ny * w + nx) * ch + c]; + lo = lo.min(nv); + hi = hi.max(nv); + } + s = s.clamp(lo, hi); + } + out[o + c] = s; + } + } + } + out +} diff --git a/crates/engine/examples/bench_sharpen_tool.rs b/crates/engine/examples/bench_sharpen_tool.rs new file mode 100644 index 0000000000..5a5788b443 --- /dev/null +++ b/crates/engine/examples/bench_sharpen_tool.rs @@ -0,0 +1,62 @@ +//! Reproducible soft Sharpen strokes on a 24 MP striped document. Run in release mode. +//! Setup, Undo and the output fingerprint are excluded from the timing. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +fn run() -> Result<(), Box> { + for depth in [8, 16] { + let mut session = Session::new(); + session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + session.execute("layer.new.layer", json!({}))?; + session.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color); + } + Ok(()) + })?; + for size in [100, 500, 1000] { + let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25,"protectDetail":true}); + let mut times = Vec::new(); + let mut fingerprint = 0u64; + for sample in 0..6 { + let start = Instant::now(); + session.execute("paint.sharpen", params.clone())?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + if sample > 0 { + times.push(ms); + } + if sample == 5 { + let st = session.active().ok_or("no document")?; + let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?; + for y in (1500..2500).step_by(17) { + for x in (2000..4000).step_by(13) { + for channel in surface.rgba(x, y) { + fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits()); + } + } + } + } + session.execute("edit.undo", json!({}))?; + } + times.sort_by(f64::total_cmp); + println!( + "depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}", + times[2], times[0], times[4] + ); + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} From 82096cff3a98e2f93a04a983de8c95b19486693f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:42:33 +0530 Subject: [PATCH 28/28] perf(retouch): avoid duplicate rasterization while drawing (#9532) * perf(retouch): generate live dabs without unused coverage * fix(ui): box live stroke variants after retouch state reduction --- crates/engine/examples/bench_live_dab.rs | 119 ++++++++++++++++++++ crates/engine/src/cmd/retouch_cmds.rs | 2 + crates/engine/src/cmd/retouch_cmds/dab.rs | 51 ++++++--- crates/engine/src/cmd/retouch_cmds/tests.rs | 98 ++++++++++++++++ crates/ui-egui/src/canvas/strokes.rs | 20 ++-- 5 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 crates/engine/examples/bench_live_dab.rs diff --git a/crates/engine/examples/bench_live_dab.rs b/crates/engine/examples/bench_live_dab.rs new file mode 100644 index 0000000000..3e98a44789 --- /dev/null +++ b/crates/engine/examples/bench_live_dab.rs @@ -0,0 +1,119 @@ +//! Live retouch latency on a materialized 24 MP layer. Run the same example at base and head +//! in release mode on one machine. Setup, Undo and the full output fingerprint are not timed. +//! This measures engine stroke work; canvas compositing/upload and presentation are excluded. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab}; +use photocraft_geom::Rect; +use serde_json::json; + +fn elapsed_ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1000.0 +} + +fn median(values: &mut [f64]) -> f64 { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied().unwrap_or(0.0) +} + +fn run() -> Result<(), Box> { + // Four pointer samples per 60 Hz frame, ~1000 px/s, over a 960 px stroke. + // Large soft tips also exercise pixels beyond the canvas edges. + let points: Vec<_> = (0..=240).map(|i| StrokePoint::new(2500.0 + f64::from(i) * 4.0, 2000.0, 1.0)).collect(); + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + s.execute("layer.new.layer", json!({}))?; + s.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + let fmt = surface.format(); + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 0.7] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &photocraft_raster::from_rgba(&fmt, color)); + } + Ok(()) + })?; + for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] { + for size in [300, 600, 1000, 2500] { + for hardness in [0, 100] { + let params = json!({ + "points":points.iter().map(|q| [q.x, q.y]).collect::>(), + "size":size,"hardness":hardness,"strength":50,"spacing":25 + }); + let mut start_params = params.clone(); + start_params["points"] = json!([[2500, 2000]]); + let (mut draws, mut worst_frames, mut releases) = (Vec::new(), Vec::new(), Vec::new()); + let mut fingerprint = None; + // One warm-up followed by three measured strokes, always starting from the same pixels. + for sample in 0..4 { + let start = Instant::now(); + let mut live = LiveDab::begin(&s, cmd, &start_params)?; + let mut draw = elapsed_ms(start); + let mut worst = draw; + for batch in points.get(1..).unwrap_or_default().chunks(4) { + let start = Instant::now(); + live.push(batch)?; + let duration = elapsed_ms(start); + draw += duration; + worst = worst.max(duration); + } + let bounds = live.bounds(); + let start = Instant::now(); + s.offer_live_dab(live)?; + let result = s.execute(cmd, params.clone())?; + let release = elapsed_ms(start); + if result.get("fromLive").and_then(|v| v.as_bool()) != Some(true) { + return Err("the commit did not take the live pixels".into()); + } + let st = s.active().ok_or("no document")?; + let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?; + // Include finishing dabs, without depending on a new API unavailable at base. + let damage = result.get("damage").and_then(|v| v.as_array()).ok_or("missing damage")?; + if damage.len() != 4 { + return Err("invalid damage length".into()); + } + let mut coords = [0i32; 4]; + for (coord, value) in coords.iter_mut().zip(damage) { + *coord = i32::try_from(value.as_i64().ok_or("invalid damage")?)?; + } + let [x, y, width, height] = coords; + let bounds = bounds.union(&Rect::new(x, y, x.saturating_add(width), y.saturating_add(height))); + let mut hash = 0xcbf29ce484222325u64; + // Hash every channel of the modified rectangle, one row at a time to bound memory. + for y in bounds.y0..bounds.y1 { + for channel in surface.read_region(Rect::new(bounds.x0, y, bounds.x1, y + 1)) { + hash = (hash ^ u64::from(channel.to_bits())).wrapping_mul(0x100000001b3); + } + } + if fingerprint.is_some_and(|previous| previous != hash) { + return Err("repeated strokes produced different pixels".into()); + } + fingerprint = Some(hash); + s.execute("edit.undo", json!({}))?; + if sample > 0 { + draws.push(draw); + worst_frames.push(worst); + releases.push(release); + } + } + println!( + "{cmd} depth={depth} size={size} hardness={hardness} draw_ms={:.3} worst_frame_ms={:.3} release_ms={:.3} fingerprint={:016x}", + median(&mut draws), + median(&mut worst_frames), + median(&mut releases), + fingerprint.unwrap_or_default() + ); + } + } + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index bb754e13a0..96cb4e0b0b 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -220,6 +220,8 @@ mod sampling; pub use clone::{LiveRetouch, clone_preview, clone_preview_periodic}; use clone::{blend_param, clamp_samples, clone_stamp}; pub(crate) use dab::DabEffect; +#[cfg(test)] +use dab::DabTool; pub use dab::LiveDab; use dab::dab_cmd; #[cfg(test)] diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 7ff5a600e6..a0f5e3a34d 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -125,7 +125,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result { /// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and /// turned into its per-dab effect, for the commit and the live preview alike. #[derive(Clone, Debug, PartialEq)] -enum DabTool { +pub(super) enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 }, @@ -168,7 +168,7 @@ impl DabNeighborhood { impl DabTool { /// The tool for `cmd` and its history label. - fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { + pub(super) fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { Ok(match cmd { "paint.dodge" | "paint.burn" => { let burn = cmd == "paint.burn"; @@ -216,7 +216,7 @@ impl DabTool { } /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { let sp = spacing; match self.clone() { Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), @@ -380,7 +380,11 @@ pub struct LiveDab { /// The active document with the stroke so far. pub doc: std::sync::Arc, cmd: String, - renderer: photocraft_paint::StrokeRenderer, + ctx: photocraft_paint::BrushContext, + generator: photocraft_paint::dynamics::DabGenerator, + /// Only the current batch is retained; sequential effects don't use accumulated coverage. + pending: Vec, + dual_pending: Vec, done: usize, effect: DabEffect, neighborhood: DabNeighborhood, @@ -484,12 +488,17 @@ impl LiveDab { let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); let effect = effect(&pre_surf); - // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). - let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer: + // its accumulated coverage isn't used here, and each effect rasterizes its own footprint. + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let generator = photocraft_paint::dynamics::DabGenerator::new(&stroke.brush, 1.0); let mut live = Self { doc: std::sync::Arc::new(doc), cmd: cmd.into(), - renderer, + ctx, + generator, + pending: Vec::new(), + dual_pending: Vec::new(), done: 0, effect, neighborhood, @@ -523,11 +532,13 @@ impl LiveDab { self.bounds } - /// Run the dabs the renderer has generated since the last call on the working copy; returns - /// the rectangle they changed. + /// Run this batch's primary dabs in order, rasterizing each footprint exactly once. fn run_new_dabs(&mut self) -> Rect { - let ctx = self.renderer.ctx.clone(); - let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); + let ctx = &self.ctx; + let mut new = std::mem::take(&mut self.pending); + // As in `apply_dab_stroke`, sequential effects consume only primary footprints. Keep + // generating dual dabs so the generator's state advances just as it did in StrokeRenderer. + self.dual_pending.clear(); let mut dmg = Rect::EMPTY; for (k, d) in new.iter().enumerate() { let r = ctx.dab_rect(d, false); @@ -535,11 +546,13 @@ impl LiveDab { continue; } let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); } - self.done += new.len(); + self.done = self.done.saturating_add(new.len()); + new.clear(); + self.pending = new; dmg } @@ -557,18 +570,20 @@ impl LiveDab { crate::brush_cmds::check_coords(pts, &self.cmd)?; let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); - self.renderer.push(&pts); + self.generator.push(&pts, &mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); - let ctx = self.renderer.ctx.clone(); + let ctx = &self.ctx; // The finishing dabs, run on a scratch copy of the working pixels. let mut tail = None; if self.preview_tail { - let ds = self.renderer.tail_dabs(); + let mut generator = self.generator.clone(); + let (mut ds, mut dual) = (Vec::new(), Vec::new()); + generator.finish(&mut ds, &mut dual); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo())); for (k, d) in ds.iter().enumerate() { - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); } tail = Some((r, region.crop(r))); } @@ -605,7 +620,7 @@ impl LiveDab { if now != tool || stroke.brush != *brush || id != self.id { return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke")); } - self.renderer.finish(); + self.generator.finish(&mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); let shown = dmg.union(&self.tail); if !shown.is_empty() { diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index a1329064d2..25ebcc8a97 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1851,6 +1851,104 @@ fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() { assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's"); s.execute("edit.undo", json!({})).unwrap(); assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(layer_pixels(&s), want, "{cmd} {depth} {hardness}: redo restores the live commit"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } +} + +/// Compare every displayed batch and the finishing dabs with the former StrokeRenderer path, +/// including features whose coverage is unnecessary to sequential effects (dual tips/Wet Edges). +#[test] +fn live_dab_batches_match_the_accumulating_renderer() { + use photocraft_paint::{GrayTile, StrokeRenderer, TipShape}; + + for depth in DEPTHS { + let mut s = session(200, 90, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, y| { + let mut c = texture(x, y); + c[3] = 0.25 + ((x + y) % 7) as f32 * 0.1; + c + }); + let id = s.active().unwrap().active_layer.unwrap(); + let pre = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + for case in ["soft", "hard", "sampled", "dynamics", "smoothing"] { + s.tools.brush = BrushSettings::default(); + match case { + "sampled" => s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(9, 7, |x, y| ((x * 3 + y) % 11) as f32 / 10.0)), + "dynamics" => { + let b = &mut s.tools.brush; + b.scattering.enabled = true; + b.scattering.scatter.jitter = 0.5; + b.scattering.count = 2; + b.shape_dynamics.enabled = true; + b.shape_dynamics.angle.jitter = 0.7; + b.shape_dynamics.size.jitter = 0.3; + b.dual_brush.enabled = true; + b.dual_brush.size = 12.0; + b.wet_edges = true; + b.noise = true; + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = photocraft_paint::Pattern::Tile(GrayTile::from_fn(3, 3, |x, y| (x + y) as f32 / 4.0)); + } + "smoothing" => s.tools.brush.smoothing.amount = 0.65, + _ => {} + } + let points = [ + StrokePoint::new(-5.0, 30.0, 0.3), + StrokePoint::new(70.0, 40.0, 1.0), + StrokePoint::new(150.0, 55.0, 0.6), + StrokePoint::new(60.0, 35.0, 0.8), + StrokePoint::new(61.0, 35.5, 0.2), + ]; + for (cmd, extra) in dab_cases() { + let mut p = dab_opts(&[[-5.0, 30.0]], if case == "hard" { 100 } else { 0 }, &extra); + p["points"] = json!([[-5.0, 30.0, 0.3]]); + p["opacity"] = json!(57); + p["flow"] = json!(63); + let (stroke, _) = parse_brush(&s, &p, cmd).unwrap(); + let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap(); + let mut effect = tool.effect(pre.format(), stroke.brush.spacing); + let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // A fixed region containing all dabs plus their read neighbourhoods. + let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256)); + let mut work = orig.clone(); + let mut shown = pre.clone(); + let mut done = 0; + let mut live = LiveDab::begin(&s, cmd, &p).unwrap(); + let mut bounds = Rect::EMPTY; + let mut render_new = |reference: &StrokeRenderer| { + let mut damage = Rect::EMPTY; + for (i, dab) in reference.dabs().iter().enumerate().skip(done) { + let fp = reference.ctx.footprint(dab, i); + effect(&mut work, &fp); + damage = damage.union(&fp.rect); + } + done = reference.dabs().len(); + if !damage.is_empty() { + photocraft_paint::retouch::mix_back(&mut shown, &orig, &work, damage, stroke.brush.opacity, None, false); + } + bounds = bounds.union(&damage); + (bounds, shown.read_region(bounds)) + }; + for (batch_index, batch) in std::iter::once(&points[..1]).chain(points[1..].chunks(2)).enumerate() { + reference.push(batch); + if batch_index > 0 { + live.push(batch).unwrap(); + } + let (bounds, expected) = render_new(&reference); + let actual = live.doc.layer(id).unwrap().surface().unwrap(); + assert!(actual.read_region(bounds) == expected, "{cmd} {depth} {case} batch={batch_index}"); + } + reference.finish(); + let (bounds, expected) = render_new(&reference); + let commit = live.into_commit(&s).unwrap(); + assert_eq!(commit.bounds, bounds, "{cmd} {depth} {case}: final damage"); + assert!(commit.surface.read_region(bounds) == expected, "{cmd} {depth} {case}: finishing dabs"); } } } diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 15aa741a82..92b8ee2802 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -115,8 +115,8 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte /// Healing Brush and History Brush composite their source. pub(super) enum EngineStroke { Brush(Box), - Retouch(photocraft_engine::retouch_cmds::LiveRetouch), - Dab(photocraft_engine::retouch_cmds::LiveDab), + Retouch(Box), + Dab(Box), Mixer(Box), } @@ -304,32 +304,32 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::color_replacement_params(app); p["points"] = json!(d.points); - EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?)) } else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { // The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed. p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)) } else if d.tool == Tool::PatternStamp { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points); p["points"] = json!(d.points); p["target"] = paint_target(app); if app.ui.tool_options.pattern_stamp_impressionist { - EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?)) } else { - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?)) } } else if d.tool == Tool::BackgroundEraser { // The params `eraser_ui::finish_stroke` commits (layer pixels only). let p = crate::eraser_ui::background_eraser_params(app, &d.points); - EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?)) } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)) } else { let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) @@ -433,12 +433,12 @@ pub(super) fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { match stroke { EngineStroke::Retouch(mut live) if !dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_retouch(live); + let _ = app.session.offer_live_retouch(*live); } } EngineStroke::Dab(mut live) if dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_dab(live); + let _ = app.session.offer_live_dab(*live); } } _ => {}