From bbfaefd5f03c681e1d14d63a76226122627043e3 Mon Sep 17 00:00:00 2001 From: Erica Franco Date: Mon, 12 Oct 2026 17:00:03 -0600 Subject: [PATCH 1/7] File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preference was stored but read nowhere, so Edit › Preferences hid it. Never now writes Photoshop's white placeholder instead of the merged image and records has_real_merged_data = false in resource 1057, without rendering the composite: smaller files and faster saves. Always and Ask When Saving keep the real merged image, as Image Previews already does with Ask. A file without layer records always keeps its real merged image, and layered TIFF still renders the composite it is made of. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 1 + apps/photocraft/src/services.rs | 1 + crates/engine/src/file_cmds.rs | 28 +++-- crates/engine/src/file_cmds/tests.rs | 20 +++ crates/engine/src/prefs.rs | 1 - crates/io/src/lib.rs | 16 ++- crates/io/src/psd_export.rs | 59 +++++++-- crates/io/src/tiff_layers.rs | 3 +- crates/io/tests/psd_maximize_compatibility.rs | 115 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 29 +++-- crates/ui-egui/src/save_identity_tests.rs | 23 ++++ docs/scorecard.md | 6 +- 12 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 crates/io/tests/psd_maximize_compatibility.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..16781e42bb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -347,6 +347,7 @@ fn services(inbox: Inbox) -> Services { opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..fea0d34541 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -217,6 +217,7 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; opts } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 5aa2441a1f..74069c8b8c 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -218,8 +218,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { Ok(r.document) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits and PSD -/// previews. +/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD +/// previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -231,25 +231,30 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility). + pub psd_maximize_compatibility: bool, } impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } } } impl SaveOpts { /// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than - /// 24 or 32 is an error); the image previews from the session's File Handling preferences - /// (Ask When Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox - /// starts on). + /// 24 or 32 is an error); the image previews and the PSD merged image from the session's File + /// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's + /// Save As checkbox and its Maximize Compatibility checkbox both start on). pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { + let file_handling = &s.prefs().file_handling; Ok(SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, - image_previews: s.prefs().file_handling.image_previews != crate::prefs::Ask::Never, + image_previews: file_handling.image_previews != crate::prefs::Ask::Never, + psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) } @@ -283,8 +288,13 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; let save = save.into(); - let mut opts = - photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: save.tiff_layers, + tga_bits: save.tga_bits, + image_previews: save.image_previews, + psd_maximize_compatibility: save.psd_maximize_compatibility, + ..Default::default() + }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index a4100a138d..8cab1ce635 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -192,6 +192,26 @@ fn image_previews_preference_controls_the_psd_thumbnail() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility decides whether a +/// layered PSD keeps its real merged image: Always and Ask When Saving write it, Never writes +/// Photoshop's placeholder and says so in resource 1057. +#[test] +fn maximize_compatibility_preference_controls_the_psd_merged_image() { + use crate::prefs::Ask; + let dir = tmp("maximize"); + let mut s = session(300, 200, 16); + s.execute("layer.new.layer", json!({})).unwrap(); + let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + for (ask, expect) in [(Ask::Always, true), (Ask::Never, false), (Ask::Ask, true)] { + s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); + for ext in ["psd", "psb"] { + let out = join(&dir, &format!("{ask:?}.{ext}")); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert_eq!(real_merged(&out), Some(expect), "{ask:?} {ext}"); + } + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 074835fd03..3a8b7ce695 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1054,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", "fileHandling.saveInBackground", - "fileHandling.maximizePsdCompatibility", "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c387dc4e30..6d33c332a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -177,11 +177,23 @@ pub struct ExportOptions { /// PSD/PSB: embed the thumbnail image resource (1036), Preferences › File Handling › Image /// Previews. On by default, as Photoshop's Always Save. pub image_previews: bool, + /// PSD/PSB: write the real merged image, Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as + /// Photoshop's Always. + pub psd_maximize_compatibility: bool, } impl Default for ExportOptions { fn default() -> Self { - ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, xmp: XmpEmbed::All, tga_bits: None, image_previews: true } + ExportOptions { + encode: EncodeOptions::default(), + force_psb: false, + tiff_layers: false, + xmp: XmpEmbed::All, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -382,7 +394,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; + let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 36f5941f78..38db080873 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -35,11 +35,17 @@ pub struct PsdExportOptions { pub merged_matte: bool, /// Compression of the merged image (32-bit float documents are always written raw). pub merged_compression: Compression, + /// Write the real merged image (Preferences › File Handling › Maximize PSD and PSB File + /// Compatibility). `false` writes Photoshop's white placeholder instead and records + /// `has_real_merged_data = false` in resource 1057: a smaller file and a faster save, but + /// readers that only show the merged image see white. A document without layer records always + /// gets the real image, since its pixels live nowhere else. + pub maximize_compatibility: bool, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle } + PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } } } @@ -1227,6 +1233,28 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl (planes, has_alpha, translucent) } +/// The merged image of a file saved without Maximize Compatibility: Photoshop's white placeholder +/// in the document's colour channels, with no alpha. Encoded like [`merged_planes`], so CMYK white +/// is no ink and Lab white is L 100 with neutral a*/b*. +fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec { + let cc = fmt.mode.color_channels(); + let cmyk = cmyk_of(fmt); + let lab16 = fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16; + let space = photocraft_compose::cmyk_space(doc); + let white = photocraft_color::convert::with_cmyk_space(space.as_ref(), || photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0])); + let n = doc.size.area() as usize; + let mut planes = Vec::with_capacity(n * fmt.sample.bytes() * cc); + for c in 0..cc { + let v = white.get(c).copied().unwrap_or(1.0); + let x = if cmyk { 1.0 - v } else { v }; + let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; + let mut sample = Vec::with_capacity(4); + encode_be(x, fmt.sample, &mut sample); + planes.extend(sample.iter().copied().cycle().take(n * sample.len())); + } + planes +} + /// Converts a document to a PSD file model (see [`document_to_psd_with`]). pub fn document_to_psd(doc: &Document) -> PsdFile { document_to_psd_with(doc, &PsdExportOptions::default()).0 @@ -1470,15 +1498,24 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let space = photocraft_compose::cmyk_space(doc); photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick)); - // Merged composite, rendered and encoded in bands (no full-size float composite). Matting - // against white only changes pixels with alpha < 1; if some are slightly translucent but all - // round to opaque (so no alpha channel is written), encode once more without the matte. - let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; - let (mut planes, has_alpha, translucent) = - crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); - if !has_alpha && translucent && opts.merged_matte { - planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; - } + // Without Maximize Compatibility the merged image is a white placeholder and the composite + // isn't rendered at all. A document without layer records keeps the real image: its pixels + // live nowhere else. + let real_merged = opts.maximize_compatibility || ex.records.is_empty(); + let (mut planes, has_alpha) = if real_merged { + // Merged composite, rendered and encoded in bands (no full-size float composite). Matting + // against white only changes pixels with alpha < 1; if some are slightly translucent but + // all round to opaque (so no alpha channel is written), encode once more without the matte. + let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; + let (mut planes, has_alpha, translucent) = + crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); + if !has_alpha && translucent && opts.merged_matte { + planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; + } + (planes, has_alpha) + } else { + (placeholder_planes(doc, &fmt), false) + }; let n = doc.size.area() as usize; if !has_alpha { planes.truncate(cc * n * sample.bytes()); @@ -1623,7 +1660,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if let Some(Some(data)) = slices_resource { resources.push(ImageResource::new(crate::slices_map::SLICES, data)); } - resources.push(version_info_resource(true)); + resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); let placement = match (has_layers, sample) { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3142bc1a8c..0aca0fe882 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -201,7 +201,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw }; + let psd_opts = + PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/psd_maximize_compatibility.rs new file mode 100644 index 0000000000..c8f277f6ee --- /dev/null +++ b/crates/io/tests/psd_maximize_compatibility.rs @@ -0,0 +1,115 @@ +//! Preferences › File Handling › Maximize PSD and PSB File Compatibility: turned off, a layered +//! PSD/PSB stores Photoshop's white placeholder instead of the merged image and says so in +//! resource 1057 (`has_real_merged_data = false`). Layers, alpha channels and the Quick Mask are +//! written as usual, so the file reopens the same. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +fn compat_off() -> ExportOptions { + ExportOptions { psd_maximize_compatibility: false, ..Default::default() } +} + +fn parse(bytes: &[u8]) -> PsdFile { + PsdFile::from_bytes(bytes).expect("parse") +} + +/// A `w`×`h` document of `mode`/`depth` holding one layer of `rgba` over the whole canvas. +fn filled(mode: ColorMode, depth: SampleType, w: u32, h: u32, rgba: [f32; 4]) -> Document { + let mut d = Document::new("compat", Size::new(w, h), mode, depth); + let fmt = d.pixel_format(); + let mut px = vec![0.0; fmt.channels()]; + photocraft_raster::from_rgba_into(&fmt, rgba, &mut px); + let mut layer = Layer::raster("pixels", fmt); + layer.surface_mut().unwrap().fill_rect(Rect::new(0, 0, w as i32, h as i32), &px); + d.layers.push(layer); + d +} + +/// An 8-bit RGB document whose layer is seeded noise, alpha included: its merged image barely +/// compresses, unlike the placeholder. +fn noisy(w: u32, h: u32) -> Document { + let mut d = Document::new("noise", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut state = 0x2545_f491_u32; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state & 0xff) as f32 / 255.0 + }; + let vals: Vec = (0..(w * h) as usize * fmt.channels()).map(|_| next()).collect(); + let mut layer = Layer::raster("noise", fmt); + layer.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &vals); + d.layers.push(layer); + d +} + +#[test] +fn off_writes_the_white_placeholder_and_flags_it() { + let d = noisy(96, 64); + for name in ["x.psd", "x.psb"] { + let no_previews = |o: ExportOptions| ExportOptions { image_previews: false, ..o }; + let on = export(&d, name, &no_previews(ExportOptions::default())).unwrap(); + let off = export(&d, name, &no_previews(compat_off())).unwrap(); + let (real, placeholder) = (parse(&on.bytes), parse(&off.bytes)); + assert_eq!(real.has_real_merged_data(), Some(true), "{name}"); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{name}"); + assert!(real.merged_has_alpha() && !placeholder.merged_has_alpha(), "{name}: the placeholder has no alpha"); + assert!(placeholder.composite_rgba8().unwrap().data.iter().all(|&b| b == 255), "{name}: opaque white"); + assert_eq!(real.layers(), placeholder.layers(), "{name}: the layers are written the same"); + assert!(off.bytes.len() * 3 < on.bytes.len() * 2, "{name}: {} bytes without the merged image, {} with it", off.bytes.len(), on.bytes.len()); + } +} + +/// The placeholder is exactly the white a real merged image would store, in every colour model +/// and depth PSD has: CMYK white is no ink, Lab white is L 100 with neutral a*/b*. +#[test] +fn the_placeholder_is_the_white_a_real_merged_image_stores() { + use ColorMode::{Cmyk, Grayscale, Lab, Rgb}; + use SampleType::{F32, U8, U16}; + for (mode, depth) in + [(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)] + { + let d = filled(mode, depth, 9, 5, [1.0; 4]); + let real = parse(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes); + let placeholder = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{mode:?} {depth:?}"); + assert!(!real.merged_has_alpha(), "{mode:?} {depth:?}: an opaque document"); + assert_eq!(placeholder.header, real.header, "{mode:?} {depth:?}"); + assert_eq!(placeholder.decode_merged().unwrap(), real.decode_merged().unwrap(), "{mode:?} {depth:?}"); + } +} + +#[test] +fn layers_and_alpha_channels_reopen_unchanged() { + let mut d = noisy(32, 16); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + selection.fill_rect(Rect::new(0, 0, 10, 16), &[1.0]); + d.channels.push(AlphaChannel::new("Alpha 1", selection.clone())); + d.quick_mask = Some(AlphaChannel::new("Quick Mask", selection)); + let reopen = |opts: &ExportOptions| { + let r = import("x.psd", &export(&d, "x.psd", opts).unwrap().bytes).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + r.document + }; + let (on, off) = (reopen(&ExportOptions::default()), reopen(&compat_off())); + assert_eq!(off.layers.len(), 1); + assert_eq!(off.channels, on.channels, "alpha channels follow the placeholder"); + assert_eq!(off.quick_mask, on.quick_mask); + let canvas = d.bounds(); + assert_eq!(photocraft_compose::render(&off, canvas).px, photocraft_compose::render(&on, canvas).px, "the same image"); +} + +/// Without layer records the merged image is the only copy of the pixels, so it stays real. +#[test] +fn a_file_without_layers_keeps_its_real_merged_image() { + let d = Document::new("empty", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let file = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert!(file.layers().is_empty()); + assert_eq!(file.has_real_merged_data(), Some(true)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5e54fd2748..1092f4081f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,11 +250,23 @@ pub struct ExportSettings { /// PSD/PSB: embed a thumbnail (Preferences › File Handling › Image Previews; see /// [`PhotocraftApp::save_settings`]). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility; see [`PhotocraftApp::save_settings`]). + pub psd_maximize_compatibility: bool, } impl Default for ExportSettings { fn default() -> Self { - ExportSettings { jpeg_quality: None, webp_lossless: true, webp_quality: None, tiff_layers: true, xmp_all: true, tga_bits: None, image_previews: true } + ExportSettings { + jpeg_quality: None, + webp_lossless: true, + webp_quality: None, + tiff_layers: true, + xmp_all: true, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -1315,13 +1327,16 @@ impl PhotocraftApp { } /// What a save writes by default under the preferences: File Handling › Image Previews - /// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox - /// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP - /// is written the way the active document's file was (its JPEG quality, a WebP lossy at - /// its quality or lossless), as Photoshop's Save and Save As options start there. + /// decides whether a PSD/PSB gets its thumbnail, and Maximize PSD and PSB File Compatibility + /// whether it gets its real merged image. Ask When Saving has no dialog here yet, so it saves + /// both, as Photoshop's checkboxes start on. A JPEG or WebP is written the way the active + /// document's file was (its JPEG quality, a WebP lossy at its quality or lossless), as + /// Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { - let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never; - let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() }; + let file_handling = &self.session.prefs().file_handling; + let previews = file_handling.image_previews != photocraft_engine::prefs::Ask::Never; + let maximize = file_handling.maximize_psd_compatibility != photocraft_engine::prefs::Ask::Never; + let mut settings = ExportSettings { image_previews: previews, psd_maximize_compatibility: maximize, ..ExportSettings::default() }; if let Some(st) = self.session.active() { let e = &st.doc.file_encoding; settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c0cd85eaed..c947afc9b6 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -205,6 +205,29 @@ fn image_previews_preference_reaches_the_exporter() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility reaches the exporter of +/// Save As, Save and automation saves: Always and Ask When Saving keep the PSD's real merged +/// image, Never doesn't. +#[test] +fn maximize_compatibility_preference_reaches_the_exporter() { + let seen = Rc::new(RefCell::new(Vec::new())); + let mut app = app(); + let log = seen.clone(); + app.services.export = Some(Box::new(move |_, _, settings: &ExportSettings| { + log.borrow_mut().push(settings.psd_maximize_compatibility); + Ok((b"out".to_vec(), Vec::new())) + })); + let ctx = egui::Context::default(); + for (value, expect) in [("always", true), ("never", false), ("ask", true)] { + app.run("prefs.set", json!({"path": "fileHandling.maximizePsdCompatibility", "value": value})).unwrap(); + seen.borrow_mut().clear(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": format!("{value}.psd")})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.save_automation(Some(format!("{value} agent.psd"))).unwrap(); + assert_eq!(*seen.borrow(), [expect; 3], "{value}"); + } +} + /// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, /// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. #[test] diff --git a/docs/scorecard.md b/docs/scorecard.md index 31ded86f27..d2ee641b1e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 27 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 7015f2916db4abaa9379216af5548152e5739545 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:01:24 +0200 Subject: [PATCH 2/7] View: Esc leaves full screen modes (part of #4116) (#4145) Esc returned to Standard only from Full Screen Mode, and was consumed in the UI pass before tools, dialogs and popups could see it. Handle it in the shortcut chain instead, after everything that owns Esc, for both full screen modes, via view.screenMode.standard. --- crates/ui-egui/src/lib.rs | 3 -- crates/ui-egui/src/shortcuts.rs | 25 +++++++++------ crates/ui-egui/src/view_cmds/tests.rs | 44 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1092f4081f..a144477cdb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1710,9 +1710,6 @@ impl eframe::App for PhotocraftApp { // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); let chrome = !self.ui.view.hides_chrome(); - if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({})); - } if chrome && self.ui.panels.menu_bar { panels::title_bar(self, ui); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7c0c8145f6..a156a6ce3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -531,15 +531,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - // Esc with no active operation deselects (#2674), like ⌘D. The key is only taken when a - // selection exists, and never from a state that handles Esc later in the frame: menus, - // dialogs and inline type returned above, transform, pen, lasso and crop consumed it, and - // these own theirs: full screen (Esc returns), the palette, the canvas and layer menus, the - // brush picker, Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any - // open egui popup and the toolbar tool flyout. A canvas drag in progress (a marquee being - // drawn) keeps the selection too. - let esc_taken = app.ui.view.hides_chrome() - || app.drag.is_some() + // Esc with no active operation leaves a full screen mode, else deselects (#2674), like ⌘D. + // The key is never taken from a state that handles Esc itself: menus, dialogs, text fields + // and inline type returned above, transform, pen, lasso and crop consumed it, and these own + // theirs later in the frame: the palette, the canvas and layer menus, the brush picker, + // Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup + // and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too. + let esc_taken = app.drag.is_some() || app.ui.palette_open || app.ui.canvas_tool_menu.is_some() || app.ui.layer_menu.is_some() @@ -550,6 +548,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.distort.gallery.is_some() || ctx.any_popup_open() || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::new("tool-flyout")).is_some()); + // Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop + // (#4116), through the menu command so the window and the Screen Mode check follow. + if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) { + if let Err(e) = crate::menus::invoke(app, ctx, "view.screenMode.standard", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } if !esc_taken && app.session.active().is_some_and(|st| st.doc.selection.is_some()) && pressed(Key::Escape) { if let Err(e) = app.run("select.deselect", json!({})) { app.ui.status = e; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 34c93ccc25..3de1a20b4b 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -369,3 +369,47 @@ fn load_selection_without_alpha_channels_offers_the_composite() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); crate::dialogs::confirm(&mut app, dialog).unwrap(); } + +/// One frame of the shortcut handler with a plain Esc press. +fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { + let key = egui::Key::Escape; + let input = egui::RawInput { + focused: true, + events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { + let (mut app, ctx) = app_with(1); + // Both full screen modes return to Standard, and the menu check follows (#4116). + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert_eq!(checked(&app, "view.screenMode.standard"), Some(true)); + } + // In Standard, Esc leaves the mode alone. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + // An open dialog takes Esc first. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + menu(&mut app, &ctx, "image.canvasSize", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + app.ui.dialogs.clear(); + // So does Free Transform: Esc cancels it and the mode stays. + app.run("select.all", json!({})).unwrap(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + press_esc(&mut app, &ctx); + assert!(app.ui.transform.is_none()); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + // A selection stays while Esc leaves full screen; the next Esc deselects. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} From 13f5c104f6e784d32b74dd4f078efa7cf4c5226f Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:02:15 -0500 Subject: [PATCH 3/7] Map the remaining frequent Photoshop action events (#219) (#4465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame centre), as a Photoshop action records a Free Transform; image.applyImage takes a source layer by name. - .atn events: transform (layer → translate or relative transform; selection → transformSelection), applyImageEvent, export (Save for Web, with the txtu path record decoded and written back; kept verbatim without a destination), newPlacedLayer / placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close, delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections → select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events. - Export tries every event listing a command id (Quick Mask on → set, off → clearEvent). - Docs: event counts in the parity docs. --- crates/engine/src/atn_cmds.rs | 420 +++++++++++++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 192 +++++++++++++ crates/engine/src/channel_cmds.rs | 21 +- crates/engine/src/transform_cmds.rs | 59 +++- docs/file-format-parity.md | 2 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 6 +- 7 files changed, 689 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 4e5de06970..fa9e649c0b 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -270,6 +270,31 @@ fn color_desc(hex: &str) -> Option { Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?))) } +/// A `Pth ` value's text: Photoshop stores `"txtu"`, a little-endian total length, a +/// little-endian UTF-16 unit count and the units (NUL-terminated). +fn path_text(v: &D) -> Option { + let D::Path(b) = v else { return None }; + if b.get(0..4)? != b"txtu" { + return None; + } + let n = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?) as usize; + let units: Vec = b.get(12..12usize.checked_add(n.checked_mul(2)?)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + let text = String::from_utf16_lossy(&units); + let text = text.trim_end_matches('\0'); + (!text.is_empty()).then(|| text.to_string()) +} + +fn path_value(text: &str) -> D { + let units: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let mut b = b"txtu".to_vec(); + b.extend_from_slice(&((12 + units.len() * 2) as u32).to_le_bytes()); + b.extend_from_slice(&(units.len() as u32).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + D::Path(b) +} + /// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. const BLEND_MODES: &[(&str, &str)] = &[ ("Nrml", "Normal"), @@ -713,6 +738,60 @@ fn selective_to(p: &Value) -> Option { ) } +/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. +fn gradient_map_from(d: &Descriptor) -> Option { + let grad = get_obj(d, "Grad")?; + let stops: Option> = get_list(grad, "Clrs")? + .iter() + .map(|v| match v { + D::Descriptor(stop) => Some(json!([(get_num(stop, "Lctn")? / 4096.0).clamp(0.0, 1.0), color_hex(get_obj(stop, "Clr ")?)?])), + _ => None, + }) + .collect(); + let mut out = json!({"stops": stops?}); + if let Some(r) = get_bool(d, "Rvrs") { + out["reverse"] = json!(r); + } + if let Some(dd) = get_bool(d, "Dthr") { + out["dither"] = json!(dd); + } + Some(out) +} + +fn gradient_map_to(p: &Value) -> Option { + let stops: Option> = p + .get("stops")? + .as_array()? + .iter() + .map(|st| { + let a = st.as_array()?; + Some(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(a.get(1)?.as_str()?)?)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", long((a.first()?.as_f64()? * 4096.0).round())) + .with("Mdpn", long(50.0)), + )) + }) + .collect(); + let transparency = D::List(vec![ + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))), + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))), + ]); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(stops?)) + .with("Trns", transparency); + Some( + desc("GdMp") + .with("Grad", D::Descriptor(grad)) + .with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false))) + .with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))), + ) +} + /// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { @@ -743,6 +822,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ adj_fields!("BlnW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, + Adjustment { class: "GdMp", suffix: "gradientMap", from: gradient_map_from, to: gradient_map_to }, ]; fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { @@ -1028,6 +1108,20 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), _ => unsupported, }, + Some(D::Descriptor(t)) if t.class_id.is("Plgn") => { + let pts: Option> = get_list(t, "Pts ") + .unwrap_or(&[]) + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + match pts { + Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})), + _ => unsupported, + } + } Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { @@ -1041,6 +1135,8 @@ fn set_from(d: &Descriptor) -> Mapped { } _ => unsupported, }, + // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. + (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1095,6 +1191,24 @@ fn set_to(id: &str, p: &Value) -> Option { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), name => sel(name_ref("Chnl", name)), }, + "select.lasso" => { + let pts: Option> = p + .get("points")? + .as_array()? + .iter() + .map(|pt| { + let a = pt.as_array()?; + Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(a.first()?.as_f64()?)).with("Vrtc", px(a.get(1)?.as_f64()?)))) + }) + .collect(); + sel(D::Descriptor(desc("Plgn").with("Pts ", D::List(pts?)))) + } + "select.editInQuickMaskMode" => { + if jbool(p, "on") != Some(true) { + return None; + } + desc("null").with("null", quick_mask_ref()) + } "select.rect" => { let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; @@ -1195,14 +1309,279 @@ fn delete_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.delete", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + // The targeted channel of a layer is its mask (what Photoshop records after making an + // adjustment layer over a selection and discarding the mask). + Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"), Some(_) => Mapped::Unsupported { harmless: false }, } } +fn quick_mask_ref() -> D { + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: code("QucM") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") }, + ]) +} + +fn clear_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})), + None => none("edit.clear"), + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn clear_to(id: &str, p: &Value) -> Option { + match id { + "select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())), + _ => None, + } +} + +/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. +fn close_from(d: &Descriptor) -> Mapped { + if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } +} + +fn close_to(_: &str, _: &Value) -> Option { + Some(desc("null").with("Svng", enm("YsN ", "N "))) +} + +/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, +/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything +/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. +fn transform_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let unsupported = Mapped::Unsupported { harmless: false }; + let mut p = Map::new(); + if let Some(o) = get_obj(d, "Ofst") { + let in_px = |k: &str| matches!(o.get(k), Some(D::UnitFloat { unit: PX, .. })); + if !in_px("Hrzn") || !in_px("Vrtc") { + return unsupported; + } + if let Some(x) = get_num(o, "Hrzn") { + p.insert("dx".into(), x.into()); + } + if let Some(y) = get_num(o, "Vrtc") { + p.insert("dy".into(), y.into()); + } + } + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY"), ("Angl", "rotate")] { + if let Some(v) = get_num(d, key) { + p.insert(param.into(), v.into()); + } + } + if d.get("Skew").is_some() || d.get("Usng").is_some() { + return unsupported; + } + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + match (ref_class(r).as_deref(), target(r)) { + (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), + (Some("Lyr"), _) => { + let Some(mut lp) = layer_target(r) else { return unsupported }; + if geometry_only { + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + return step("layer.translate", Value::Object(lp)); + } + lp.extend(p); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + lp.insert("interpolation".into(), i.into()); + } + step("edit.transform", Value::Object(lp)) + } + _ => unsupported, + } +} + +fn transform_to(id: &str, p: &Value) -> Option { + if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() { + return None; + } + let target = match id { + "edit.transform" => layer_ref(p), + "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), + _ => return None, + }; + let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa")); + d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0))))); + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] { + if let Some(v) = jnum(p, param) { + d = d.with(key, pct(v)); + } + } + if let Some(a) = jnum(p, "rotate") { + d = d.with("Angl", ang(a)); + } + if let Some(i) = jstr(p, "interpolation").and_then(|i| lookup_back(INTERPOLATION, i)) { + d = d.with("Intr", enm("Intp", i)); + } + Some(d) +} + +/// `Clcn` (Apply Image / Calculations blending) ↔ `image.applyImage`'s `blending`. +const CALC_BLEND: &[(&str, &str)] = &[ + ("Nrml", "normal"), + ("Mltp", "multiply"), + ("Scrn", "screen"), + ("Ovrl", "overlay"), + ("SftL", "softLight"), + ("HrdL", "hardLight"), + ("CDdg", "colorDodge"), + ("CBrn", "colorBurn"), + ("Drkn", "darken"), + ("Lghn", "lighten"), + ("Dfrn", "difference"), + ("Xclu", "exclusion"), + ("linearBurn", "linearBurn"), + ("linearDodge", "linearDodge"), + ("Add ", "add"), + ("Sbtr", "subtract"), +]; +const SOURCE_CHANNELS: &[(&str, &str)] = + &[("RGB", "composite"), ("CMYK", "composite"), ("Lab", "composite"), ("Trsp", "transparency"), ("Msk", "mask"), ("fsel", "selection")]; + +fn apply_image_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(with) = get_obj(d, "With") else { return unsupported }; + let Some(r) = get_ref(with, "T ") else { return unsupported }; + let mut source = Map::new(); + for item in r { + match item { + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => { + let v = trimmed(value); + let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; + source.insert("channel".into(), channel.into()); + } + ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => { + source.insert("layer".into(), name.to_string_lossy().into()); + } + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Lyr ") && trimmed(value) == "Mrgd" => { + source.insert("layer".into(), "merged".into()); + } + _ => return unsupported, + } + } + if let Some(i) = get_bool(with, "Invr") { + source.insert("invert".into(), i.into()); + } + let mut p = json!({"source": source}); + if let Some(c) = get_enum(with, "Clcl") { + match lookup(CALC_BLEND, &c) { + Some(b) => p["blending"] = json!(b), + None => return unsupported, + } + } + for (key, param) in [("Scl ", "scale"), ("Ofst", "offset"), ("Opct", "opacity")] { + if let Some(v) = get_num(with, key) { + p[param] = json!(v); + } + } + if let Some(t) = get_bool(with, "PrsT") { + p["preserveTransparency"] = json!(t); + } + step("image.applyImage", p) +} + +fn apply_image_to(_: &str, p: &Value) -> Option { + let source = p.get("source").cloned().unwrap_or(json!({})); + let channel = jstr(&source, "channel").unwrap_or("composite"); + let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; + let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }]; + match jstr(&source, "layer") { + Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), + Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), + } + let mut with = desc("null").with("T ", D::Reference(items)); + if let Some(i) = jbool(&source, "invert") { + with = with.with("Invr", D::Boolean(i)); + } + with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, jstr(p, "blending").unwrap_or("multiply"))?)); + if let Some(v) = jnum(p, "opacity") { + with = with.with("Opct", pct(v)); + } + if let Some(v) = jnum(p, "scale") { + with = with.with("Scl ", D::Double(v)); + } + if let Some(v) = jnum(p, "offset") { + with = with.with("Ofst", D::Double(v)); + } + if let Some(t) = jbool(p, "preserveTransparency") { + with = with.with("PrsT", D::Boolean(t)); + } + Some(desc("null").with("With", D::Descriptor(with))) +} + +const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; + +/// `export` (Save for Web): needs the destination path (`In`), as the command writes a file. +fn export_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(u) = get_obj(d, "Usng") else { return unsupported }; + let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; + let mut p = json!({"path": path}); + if let Some(f) = get_enum(u, "Fmt ") { + match lookup(WEB_FORMATS, &f) { + Some(f) => p["format"] = json!(f), + None => return unsupported, + } + } + for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] { + if let Some(b) = get_bool(u, key) { + p[param] = json!(b); + } + } + for (key, param) in [("Qlty", "quality"), ("NCol", "colors")] { + if let Some(v) = get_num(u, key) { + p[param] = json!(v); + } + } + if get_bool(u, "Mtt ") == Some(false) { + p["matte"] = json!("none"); + } else if let (Some(r), Some(g), Some(b)) = (get_num(u, "MttR"), get_num(u, "MttG"), get_num(u, "MttB")) { + p["matte"] = + json!(format!("#{:02x}{:02x}{:02x}", r.round().clamp(0.0, 255.0) as u8, g.round().clamp(0.0, 255.0) as u8, b.round().clamp(0.0, 255.0) as u8)); + } + step("file.export.saveForWebLegacy", p) +} + +fn export_to(_: &str, p: &Value) -> Option { + let path = jstr(p, "path")?; + let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?)); + for (key, param, default) in [ + ("Intr", "interlaced", false), + ("Trns", "transparency", true), + ("EICC", "embedIcc", false), + ("Prgr", "progressive", false), + ("Optm", "optimized", true), + ] { + u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default))); + } + if let Some(q) = jnum(p, "quality") { + u = u.with("Qlty", long(q)); + } + if let Some(c) = jnum(p, "colors") { + u = u.with("NCol", long(c)); + } + match jstr(p, "matte") { + Some("none") => u = u.with("Mtt ", D::Boolean(false)), + matte => { + let c = color_desc(matte.unwrap_or("#ffffff"))?; + u = u + .with("Mtt ", D::Boolean(true)) + .with("MttR", get_num(&c, "Rd ").map(long)?) + .with("MttG", get_num(&c, "Grn ").map(long)?) + .with("MttB", get_num(&c, "Bl ").map(long)?); + } + } + Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) +} + fn delete_to(id: &str, p: &Value) -> Option { Some(match id { "edit.clear" => desc("null"), "layer.delete" => desc("null").with("null", layer_ref(p)), + "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), _ => return None, }) } @@ -1866,6 +2245,17 @@ const EVENTS: &[Event] = &[ to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), }, adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), + Event { + code: "GdMp", + name: "gradientMapEvent", + label: "Gradient Map", + ids: &["image.adjustments.gradientMap"], + from: |d| match adjustment_by_class("GdMp") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)), + }, adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), @@ -1961,6 +2351,7 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.blackWhite", "layer.newAdjustmentLayer.channelMixer", "layer.newAdjustmentLayer.selectiveColor", + "layer.newAdjustmentLayer.gradientMap", "file.new", "file.newFromClipboard", ], @@ -1977,6 +2368,8 @@ const EVENTS: &[Event] = &[ "select.reselect", "select.loadSelection", "select.rect", + "select.lasso", + "select.editInQuickMaskMode", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2001,7 +2394,20 @@ const EVENTS: &[Event] = &[ from: select_from, to: select_to, }, - Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear"], from: delete_from, to: delete_to }, + Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to }, + Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to }, + Event { + code: "Trnf", + name: "transform", + label: "Transform", + ids: &["edit.transform", "select.transformSelection"], + from: transform_from, + to: transform_to, + }, + Event { code: "AppI", name: "applyImageEvent", label: "Apply Image", ids: &["image.applyImage"], from: apply_image_from, to: apply_image_to }, + ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]), + ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), + ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, Event { @@ -2086,7 +2492,8 @@ const EVENTS: &[Event] = &[ to: profile_to, }, // File and actions. - ev!("Cls ", "close", "Close", "file.close", &[]), + Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, + Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, @@ -2098,8 +2505,10 @@ fn event_for(item: &AtnEvent) -> Option<&'static Event> { EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) } -fn event_for_id(id: &str) -> Option<&'static Event> { - EVENTS.iter().find(|e| e.ids.contains(&id)) +/// The event `id` exports as: the first event listing it whose converter accepts the params +/// (Quick Mask on is a `set`, off a `clearEvent`). +fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> { + EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d))) } // ------------------------------------------------------------------ import @@ -2220,12 +2629,11 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { }; return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } - let e = event_for_id(id)?; let mut params = params.clone(); if id == "actions.play" && params.get("set").is_none() { params["set"] = json!(set); } - let descriptor = (e.to)(id, ¶ms)?; + let (e, descriptor) = event_and_descriptor(id, ¶ms)?; let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index ff7e040878..6d6f7bb68e 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -261,11 +261,41 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.assignProfile", json!({"profile": "srgb"})), ("file.close", json!({})), ("file.save", json!({})), + ("layer.layerMask.delete", json!({})), + ("select.lasso", json!({"points": [[1, 2], [30, 2], [15, 40]]})), + ("select.editInQuickMaskMode", json!({"on": true})), + ("select.editInQuickMaskMode", json!({"on": false})), + ("edit.transform", json!({"dx": 10, "dy": -5, "scaleX": 50, "scaleY": 75, "rotate": 12.5, "interpolation": "bicubic"})), + ("edit.transform", json!({"layer": "Shadow", "dx": 0, "dy": 0, "scaleX": 200})), + ("select.transformSelection", json!({"dx": 3, "dy": 4, "scaleX": 110, "scaleY": 110, "rotate": -30})), + ( + "image.applyImage", + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}), + ), + ("image.applyImage", json!({"source": {"channel": "red", "layer": "merged", "invert": true}, "blending": "multiply", "opacity": 50})), + ( + "file.export.saveForWebLegacy", + json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}), + ), + ( + "file.export.saveForWebLegacy", + json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}), + ), + ("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})), + ("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})), + ("layer.smartObjects.convertToSmartObject", json!({})), + ("layer.smartObjects.editContents", json!({})), + ("layer.smartObjects.updateModifiedContent", json!({})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + if *id == "file.close" { + // Close exports "don't save" and imports as a plain close; the Save answer is tested below. + assert_eq!(item.event.id(), "Cls "); + continue; + } let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id())); match (e.from)(item.descriptor.as_ref().unwrap()) { Mapped::Steps(steps) => { @@ -486,3 +516,165 @@ fn colours_convert_both_ways() { assert_eq!(color_desc("#12ab9"), None); assert_eq!(color_desc("#ééééé"), None); } + +#[test] +fn remaining_events_import_in_their_photoshop_shapes() { + // Close with the Save answer becomes Save then Close. + let close_yes = item("Cls ", "Close", Some(desc("null").with("Svng", enm("YsN ", "Ys ")))); + let close_no = item("close", "Close", Some(desc("null").with("Svng", enm("YsN ", "N ")))); + // A layer only moved is a translation; scaled, it is the relative Free Transform; the + // selection's frame goes to Transform Selection; points (not pixels) stay unsupported. + let moved = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Intr", enm("Intp", "Bcbc")); + let scaled = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(10.0)).with("Vrtc", px(0.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(true)) + .with("Intr", enm("Intp", "Bcbc")); + let sel = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("FTcs", enm("QCSt", "Qcs0")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(1014.0)).with("Vrtc", px(0.0)))); + let points = desc("null").with("null", target_ref("Lyr ")).with( + "Ofst", + D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })), + ); + // Apply Image as recorded by the frequency-separation action. + let apply = desc("null").with( + "With", + D::Descriptor( + desc("null") + .with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("RGB ") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("High-Frequency") }, + ]), + ) + .with("Clcl", enm("Clcn", "Sbtr")) + .with("Scl ", D::Double(2.0)) + .with("Ofst", D::Double(128.0)) + .with("PrsT", D::Boolean(true)), + ), + ); + // Save for Web with and without a destination. + let web = desc("null") + .with( + "Usng", + D::Descriptor( + desc("SaveForWeb") + .with("Fmt ", enm("IRFm", "PN24")) + .with("Intr", D::Boolean(false)) + .with("Trns", D::Boolean(true)) + .with("Mtt ", D::Boolean(true)) + .with("MttR", long(255.0)) + .with("MttG", long(255.0)) + .with("MttB", long(255.0)) + .with("EICC", D::Boolean(false)), + ), + ) + .with("In ", path_value("/Users/me/Desktop/out.png")); + let web_nowhere = desc("null").with("Usng", D::Descriptor(desc("SaveForWeb").with("Fmt ", enm("IRFm", "PN24")))); + // Quick Mask on/off, a polygon selection, the mask of the target layer deleted. + let qm_on = desc("null").with("null", quick_mask_ref()); + let polygon = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Descriptor(desc("Plgn").with( + "Pts ", + D::List(vec![ + D::Descriptor(desc("Pnt ").with("Hrzn", px(1.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(30.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(15.0)).with("Vrtc", px(40.0))), + ]), + )), + ); + let mask_delete = desc("null").with("null", target_ref("Chnl")); + let items = vec![ + item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(1.0)))), + close_yes, + close_no, + item("transform", "Transform", Some(moved)), + item("Trnf", "Transform", Some(scaled)), + item("transform", "Transform", Some(sel)), + item("transform", "Transform", Some(points)), + item("applyImageEvent", "Apply Image", Some(apply)), + item("export", "Export", Some(web)), + item("export", "Export", Some(web_nowhere)), + item("setd", "Set", Some(qm_on.clone())), + item("clearEvent", "Clear", Some(qm_on.clone())), + item("set", "Set", Some(polygon)), + item("delete", "Delete", Some(mask_delete)), + item("newPlacedLayer", "Convert to Smart Object", None), + item("placedLayerEditContents", "Edit Contents", None), + item("updatePlacedLayer", "Update Smart Objects", None), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let ids: Vec<&str> = steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + ids, + [ + "filter.blur.gaussianBlur", + "file.save", + "file.close", + "file.close", + "layer.translate", + "edit.transform", + "select.transformSelection", + PHOTOSHOP_STEP, + "image.applyImage", + "file.export.saveForWebLegacy", + PHOTOSHOP_STEP, + "select.editInQuickMaskMode", + "select.editInQuickMaskMode", + "select.lasso", + "layer.layerMask.delete", + "layer.smartObjects.convertToSmartObject", + "layer.smartObjects.editContents", + "layer.smartObjects.updateModifiedContent", + ] + ); + assert_eq!(steps[4].1, json!({"dx": -57, "dy": 12})); + assert_eq!(steps[5].1, json!({"dx": 10, "dy": 0, "scaleX": 50, "scaleY": 50, "interpolation": "bicubic"})); + assert_eq!(steps[6].1, json!({"dx": 1014, "dy": 0})); + assert_eq!( + steps[8].1, + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}) + ); + assert_eq!( + steps[9].1, + json!({"path": "/Users/me/Desktop/out.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "matte": "#ffffff"}) + ); + assert_eq!(steps[11].1, json!({"on": true})); + assert_eq!(steps[12].1, json!({"on": false})); + assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); + assert_eq!(imp.unsupported.len(), 2); + + // The Photoshop path record round-trips and rejects other shapes. + assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png")); + assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None); + assert_eq!(path_text(&D::Path(vec![])), None); + assert_eq!(path_text(&D::Text(UnicodeString::new("x"))), None); + assert_eq!(path_text(&path_value("")), None); + + // Apply Image by layer name runs on a document; a missing name is a clear error. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "High-Frequency"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("image.applyImage", steps[8].1.clone()).unwrap(); + let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string(); + assert!(e.contains("Nope"), "{e}"); +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 63f8f5b626..86bf55d1a1 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -328,9 +328,20 @@ fn native_pixels(doc: &Document, layer: Option) -> Result<(Vec, us } } -/// `"layer"` param of a source: a layer id, or `"merged"` (default). -fn source_layer(p: &Value) -> Option { - p.get("layer").and_then(Value::as_u64).map(LayerId) +/// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded +/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error. +fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { + match p.get("layer") { + Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), + Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc + .walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == *name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))), + _ => Ok(None), + } } /// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one @@ -425,7 +436,7 @@ fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result> return Err(bad(cmd, format!("source document \"{}\" is {}×{}; it must match the target size", st.doc.name, st.doc.size.width, st.doc.size.height))); } let r = p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))?; - let layer = source_layer(p); + let layer = source_layer(&st.doc, p, cmd)?; let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? }; // Guard every caller (`.remove(0)` / `src[0]`) against an empty source. if planes.is_empty() { @@ -1716,7 +1727,7 @@ pub fn specs() -> Vec { "Apply Image…", ["Image"], None, - r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image ), diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 684d308de0..44926b09f3 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -397,6 +397,33 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { dst.write_region(b, &d); } +/// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy` +/// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's +/// centre, then move. `None` when none of them is given. +fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { + let num = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|f| f.is_finite()).map(Some).ok_or_else(|| bad(format!("`{k}` must be a finite number"))), + } + }; + let (sx, sy, rot, dx, dy) = (num("scaleX")?, num("scaleY")?, num("rotate")?, num("dx")?, num("dy")?); + if sx.is_none() && sy.is_none() && rot.is_none() && dx.is_none() && dy.is_none() { + return Ok(None); + } + let (sx, sy) = (sx.unwrap_or(100.0) / 100.0, sy.unwrap_or(100.0) / 100.0); + if sx.abs() < 1e-6 || sy.abs() < 1e-6 { + return Err(bad("`scaleX` and `scaleY` must not be 0")); + } + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos(); + // (x, y) → translate(-c) → scale → rotate → translate(c + d). + let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos); + let e = cx + dx.unwrap_or(0.0) - a * cx - c * cy; + let f = cy + dy.unwrap_or(0.0) - b * cx - d * cy; + Ok(Some(Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]))) +} + fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> { let a = p.get("quad")?.as_array()?; if a.len() != 4 { @@ -459,8 +486,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { } // Affine [a b c d e f] maps (x, y) → (a·x + c·y + e, b·x + d·y + f). Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0]) + } else if let Some(m) = relative_matrix(p, rect)? { + m } else { - return Err(bad("pass `quad` (where the rect's corners go) or `matrix`")); + return Err(bad("pass `quad` (where the rect's corners go), `matrix`, or `scaleX`/`scaleY`/`rotate`/`dx`/`dy`")); }; if h.inverse().is_none() { return Err(bad("the transform collapses the layer")); @@ -515,7 +544,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),"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?,"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)}"##, enabled: has_layer, journal: true, run: transform, @@ -993,4 +1022,30 @@ mod tests { s.execute("layer.setProps", json!({"locks": {"position": true}})).unwrap(); assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + + #[test] + fn relative_params_scale_and_rotate_about_the_frame_centre() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + // 50% about the centre (40, 40), then 10 px right: the content ends at x 40..60, y 35..45. + let r = s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50, "dx": 10})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [20.0, 30.0, 60.0, 50.0], "the reported frame is the source frame"); + let bounds = |s: &Session| { + let d = s.active().unwrap(); + let b = transform_bounds(&d.doc, d.doc.layer(d.active_layer.unwrap()).unwrap()); + (b.x0, b.y0, b.x1, b.y1) + }; + assert_eq!(bounds(&s), (40, 35, 60, 45)); + // A quarter turn about the centre (50, 40) swaps the frame's sides. + s.execute("edit.transform", json!({"rotate": 90})).unwrap(); + assert_eq!(bounds(&s), (45, 30, 55, 50)); + for bad in [json!({"scaleX": 0}), json!({"rotate": "x"}), json!({"dx": f64::NAN})] { + assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); + } + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ecda48fee7..bc892c0ed3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (90 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (105 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | diff --git a/docs/gaps.md b/docs/gaps.md index 564138e0ae..c5e1172415 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,11 +89,13 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart - object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. + mapped events (Place, Image Size in percent, paths, conditionals, Generative Fill…) load as + kept Photoshop steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 92 Photoshop events map to commands both ways, Play and - Stop steps included; layer steps replay by layer name in any document. The panel groups actions + and `actions.load`/`actions.save`; 105 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask and polygon selections + since the third PR), Play and Stop steps included; layer steps replay by layer name in any + document. The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f5509c694..ae076b3208 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 105 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (105 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | From e2a35b04fd693f25ac44f3a08e7de019bc1483b4 Mon Sep 17 00:00:00 2001 From: Tommy Leonhardsen Date: Tue, 13 Oct 2026 01:05:37 +0200 Subject: [PATCH 4/7] Windows: keep full screen and maximized apart; never restore full screen (#4396) * Windows: keep full screen and maximized apart; never restore full screen winit on Windows keeps the maximized style on a window that goes full screen from maximized. The shell then draws the taskbar black instead of hiding it, and on the way back the window stays maximized at the monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize before entering full screen and maximize again after leaving it. eframe also restores full screen from ui.ron, as full screen and maximized at once; drop that flag in the window builder hook, so PhotoCraft starts in Standard Screen Mode as its own state says. Co-Authored-By: Claude Opus 5.5 * contributors: add aweussom * view_cmds: mark the winit#4756 workaround and how to retire it Co-Authored-By: Claude Opus 5.5 * rustfmt view_cmds.rs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/photocraft/src/main.rs | 28 ++++++++++++++++- contributors/people.toml | 3 ++ crates/ui-egui/src/view_cmds.rs | 43 ++++++++++++++++++++++++++- crates/ui-egui/src/view_cmds/tests.rs | 28 +++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index aee9eabca1..00d334758b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -90,11 +90,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { // eframe saves native window geometry and egui panel/window sizes on exit. // Keep that state beside preferences, including config overrides and portable mode. persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")), - window_builder: Some(Box::new(at_least_min_size)), + window_builder: Some(Box::new(restored_window)), ..Default::default() } } +/// The window eframe restores from `ui.ron`, corrected before it is created: at least the +/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but +/// eframe also saves and restores full screen, and a window closed in a full-screen mode is +/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps +/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode › +/// Full Screen did nothing until `F` had cycled through Standard. +fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder { + at_least_min_size(builder).with_fullscreen(false) +} + /// Grow a restored window that is smaller than the minimum size, keeping its centre. On /// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has /// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened @@ -624,6 +634,22 @@ mod tests { assert_eq!(super::at_least_min_size(builder).inner_size, None); } + #[test] + fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() { + let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook"); + // What eframe restores from a ui.ron saved in a full-screen mode on Windows. + let saved = super::native_options(super::CUSTOM_TITLEBAR) + .viewport + .with_inner_size([2560.0, 1392.0]) + .with_position([0.0, 0.0]) + .with_fullscreen(true) + .with_maximized(true); + let opened = hook(saved); + assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts"); + assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept"); + assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0))); + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { // #1271, #1316: Preferences › Interface › System Title Bar gives the window back its diff --git a/contributors/people.toml b/contributors/people.toml index 53bd154ace..82a9e20a49 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -124,3 +124,6 @@ sync_github_name = true [people.arthurdeka] sync_github_name = true + +[people.aweussom] +real_name = "Tommy Leonhardsen" diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 264550b884..33fc6affc5 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -167,6 +167,41 @@ const LAYOUTS: [&str; 10] = [ "tile", ]; +/// Whether the window was maximized when a screen mode took it full screen (egui temp data). +fn was_maximized_id() -> egui::Id { + egui::Id::new("pc-full-screen-was-maximized") +} + +/// The window commands for switching to screen mode `next`, and whether the window should be +/// maximized again when it leaves full screen. With `keep_apart` (Windows), full screen and +/// maximized never overlap: a maximized window is un-maximized before it goes full screen and +/// maximized again after it leaves. Why: winit on Windows keeps the maximized style on a window +/// that goes full screen from maximized, and the shell then draws the taskbar black instead of +/// hiding it; on the way out the window stays maximized at the monitor's full size, over the +/// taskbar (#3550, #4018, #4230). [OBSERVED 2026-10-12, Windows 11, eframe 0.36.2, winit 0.30.13, +/// a minimal eframe window; un-maximizing first gave a clean full screen and a work-area maximize.] +/// +/// WORKAROUND(winit#4756): remove `keep_apart` once the winit in Cargo.lock (0.30.13 today) has +/// the fix; check first with https://gist.github.com/aweussom/3f80ffb82df7f0102341858afbbffd9b +/// (`--max`, no `--fix`: full screen must have no WS_MAXIMIZE and hide the taskbar). +fn screen_mode_commands(keep_apart: bool, full_screen: bool, maximized: bool, was_maximized: bool, next: &str) -> (Vec, bool) { + let want = next != "standard"; + let mut commands = Vec::new(); + let mut remember = was_maximized; + if keep_apart && want && !full_screen && maximized { + commands.push(egui::ViewportCommand::Maximized(false)); + remember = true; + } + commands.push(egui::ViewportCommand::Fullscreen(want)); + if !want { + if keep_apart && full_screen && remember { + commands.push(egui::ViewportCommand::Maximized(true)); + } + remember = false; + } + (commands, remember) +} + /// Pixel aspect ratio (width / height of a pixel) of a preset id. pub fn pixel_aspect_ratio(id: &str) -> f32 { // View › Pixel Aspect Ratio › Custom: `custom::` (see `workspace_ui`). @@ -658,7 +693,13 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res }; o.screen_mode = next.to_string(); // Both full-screen modes take the whole display; the menu bar stays in the first. - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(next != "standard")); + let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false))); + let was_maximized = ctx.data(|d| d.get_temp::(was_maximized_id())).unwrap_or(false); + let (commands, was_maximized) = screen_mode_commands(cfg!(target_os = "windows"), full_screen, maximized, was_maximized, next); + ctx.data_mut(|d| d.insert_temp(was_maximized_id(), was_maximized)); + for command in commands { + ctx.send_viewport_cmd(command); + } return Ok(json!({"screenMode": next})); } if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") { diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 3de1a20b4b..b930e6e26e 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -59,6 +59,34 @@ fn show_snap_extras_and_screen_modes_are_state() { assert_eq!(v["view"]["show"]["layer_edges"], true); } +#[test] +fn full_screen_and_maximized_never_overlap_on_windows() { + use egui::ViewportCommand::{Fullscreen, Maximized}; + // Maximized → full screen: un-maximize first, and remember it. + let (cmds, remember) = screen_mode_commands(true, false, true, false, "fullScreenWithMenuBar"); + assert_eq!(cmds, vec![Maximized(false), Fullscreen(true)]); + assert!(remember); + // Full screen → the other full-screen mode: nothing about maximized changes. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "fullScreen"); + assert_eq!(cmds, vec![Fullscreen(true)]); + assert!(remember); + // Full screen → Standard: maximize again after leaving, then forget. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false), Maximized(true)]); + assert!(!remember); + // A window that was not maximized goes in and out untouched. + let (cmds, remember) = screen_mode_commands(true, false, false, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); + let (cmds, remember) = screen_mode_commands(true, true, false, false, "standard"); + assert_eq!((cmds, remember), (vec![Fullscreen(false)], false)); + // Standard while already standard never maximizes. + let (cmds, _) = screen_mode_commands(true, false, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false)]); + // Elsewhere the commands are as before. + let (cmds, remember) = screen_mode_commands(false, false, true, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); +} + #[test] fn zoom_presets_and_fit_layers() { let (mut app, ctx) = app_with(1); From 3f0a633056d5ea8c9f1a0aec2d3572f4789780bf Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 07:05:49 +0800 Subject: [PATCH 5/7] Make channel filter previews match the committed edit (#3979) * Fix selected-channel filter previews and reduced Quick Mask proxies Co-authored-by: Codex * Keep the new dialog preview aligned with the selected channel * Bound full-resolution dialog preview textures to the device limit --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/compose/src/proxy.rs | 24 ++ crates/ui-egui/src/canvas.rs | 236 +++++++++++++++++++- crates/ui-egui/src/channel_view.rs | 2 +- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/filter_dialog.rs | 15 ++ crates/ui-egui/src/filter_preview_ui.rs | 129 ++++++++++- crates/ui-egui/src/filter_preview_worker.rs | 1 + 7 files changed, 388 insertions(+), 20 deletions(-) diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 4fe3b879fb..7fe7fc3983 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -79,6 +79,9 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { c.surface = downsample(&c.surface, k); } p.selection = p.selection.as_ref().map(|s| downsample(s, k)); + if let Some(mask) = &mut p.quick_mask { + mask.surface = downsample(&mask.surface, k); + } p } @@ -172,6 +175,27 @@ mod tests { assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none()); } + #[test] + fn proxy_scales_quick_mask_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("mask", Size::new(16, 12), ColorMode::Rgb, depth); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + surface.fill_rect(Rect::new(0, 0, 8, 12), &[1.0]); + let mut mask = photocraft_doc::AlphaChannel::new("Quick Mask", surface); + mask.opacity = 0.25; + doc.quick_mask = Some(mask); + for k in [1, 2, 4] { + let proxy = proxy_document(&doc, k); + let mask = proxy.quick_mask.as_ref().unwrap(); + assert_eq!(mask.surface.pixel((8 / k - 1) as i32, 0), vec![1.0]); + assert_eq!(mask.surface.pixel((8 / k) as i32, 0), vec![0.0], "depth {depth:?}, factor {k}"); + assert_eq!(mask.opacity, 0.25); + assert_eq!(mask.surface.format().sample, depth); + } + assert_eq!(doc.quick_mask.as_ref().unwrap().surface.pixel(7, 0), vec![1.0], "source stays intact"); + } + } + #[test] fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() { let mut doc = two_colors(Size::new(32, 16), SampleType::F32); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 17de8820e3..5b6222b313 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1466,8 +1466,16 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let st = app.session.documents().get(idx)?; // Filters whose features don't scale with a proxy preview at full resolution (#2063). let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); - let request = - crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, dialog: d.id, active: st.active_layer, command: cmd, params, k }; + let request = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: cmd, + params, + k, + }; let key = request.doc.0 ^ (1u64 << 61); // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at // 1000 px takes long enough to freeze the window. Once the slider settles, a preview still @@ -1492,10 +1500,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let task = request.clone(); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_with(&doc, task.active, &task.command, &task.params, task.k, Some(cancel)).map(|doc| { - let buffer = photocraft_compose::flatten(&doc); - (std::sync::Arc::new(doc), buffer) - }); + let result = + crate::filter_dialog::preview_document_for_target(&doc, task.active, &task.command, &task.params, task.k, Some(cancel), task.target).map( + |doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }, + ); crate::filter_preview_worker::Computed { result, ms: crate::gpu_canvas::now_ms() - t0 } }) { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request.clone(), result: None, committing: false }); @@ -1512,6 +1523,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e || preview.key.revision != request.revision || preview.key.dialog != request.dialog || preview.key.active != request.active + || preview.key.target != request.target || preview.key.k != request.k || preview.key.command != request.command { @@ -1526,7 +1538,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document(&doc, request.active, &request.command, &request.params, request.k).map(std::sync::Arc::new); + let result = + crate::filter_dialog::preview_document_for_target(&doc, request.active, &request.command, &request.params, request.k, None, request.target) + .map(std::sync::Arc::new); if let Some(r) = &result { let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); @@ -1544,6 +1558,18 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some((preview.key.k, key, [result.size.width, result.size.height])) } +/// Channel overlays must use the filter's current proxy too, including on the CPU canvas. +/// The cache key changes with the completed request; unchanged frames reuse the overlay texture. +pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc, u64)> { + use std::hash::{Hash, Hasher}; + ensure_filter_preview(app, idx, ctx)?; + let preview = app.filter_preview.as_ref()?; + let doc = preview.result.as_ref()?.clone(); + let mut key = std::collections::hash_map::DefaultHasher::new(); + format!("{:?}", preview.key).hash(&mut key); + Some((doc, key.finish() | (1 << 59))) +} + fn publish_filter_preview( app: &mut PhotocraftApp, idx: usize, @@ -5407,6 +5433,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 999.0}), k: 1, @@ -5454,6 +5481,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "image.mode.indexedColor".into(), params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), k: 1, @@ -7141,6 +7169,200 @@ mod rotation_preview_isolation_tests { use super::*; use serde_json::json; + #[test] + fn channel_overlay_shows_filter_preview_and_restores_on_cancel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &ctx).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; + let st = app.session.active().unwrap(); + let original = crate::channel_view::render(&before, &st.channel_view, before.bounds(), 1, false, st.active_layer).unwrap(); + assert_ne!(image.pixels, original, "a nontrivial filter must be visible before OK"); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); + crate::channel_view::ensure(&mut app, &ctx, 0); + ctx.tex_manager().write().take_delta().clear(); + crate::dialogs::cancel(&mut app, dialog).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } + + fn assert_filter_preview_target(target: photocraft_engine::channel_cmds::ChannelTarget) { + for depth in [8, 16, 32] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":16,"height":16,"depth":depth})).unwrap(); + session.execute("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + session + .edit("Fixture", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + layer.mask = Some(photocraft_doc::LayerMask::hide_all()); + for y in 0..16 { + for x in 0..8 { + layer.surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + layer.mask.as_mut().unwrap().surface.write_pixel(x, y, &[1.0]); + } + } + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + session.active_mut().unwrap().channel_view.target = target; + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.background_jobs = false; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &egui::Context::default()).unwrap(); + let preview = app.filter_preview.as_ref().unwrap().result.as_ref().unwrap().clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let final_doc = &app.session.active().unwrap().doc; + let bounds = final_doc.bounds(); + assert_eq!( + preview.layers[0].surface().unwrap().read_region(bounds), + final_doc.layers[0].surface().unwrap().read_region(bounds), + "RGB: {depth} {target:?}" + ); + assert_eq!(preview.channels[0].surface.read_region(bounds), final_doc.channels[0].surface.read_region(bounds), "alpha: {depth} {target:?}"); + assert_eq!( + preview.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + final_doc.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + "mask: {depth} {target:?}" + ); + } + } + + #[test] + fn filter_preview_matches_applied_alpha_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Alpha(0)); + } + + #[test] + fn filter_preview_matches_applied_color_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Color(0)); + } + + #[test] + fn filter_preview_matches_applied_layer_mask_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::LayerMask); + } + + #[test] + fn filter_preview_refreshes_when_channel_target_changes() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + for name in ["First", "Second"] { + app.run("channel.new", json!({"name":name,"fill":"black"})).unwrap(); + } + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[1].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + let ctx = egui::Context::default(); + let (first, first_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + let (second, second_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_ne!(first_key, second_key); + assert_ne!(first.channels[1].surface.read_region(first.bounds()), second.channels[1].surface.read_region(second.bounds())); + let (unchanged, unchanged_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert!(std::sync::Arc::ptr_eq(&second, &unchanged)); + assert_eq!(second_key, unchanged_key); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let applied = &app.session.active().unwrap().doc; + assert_eq!(second.channels[1].surface.read_region(second.bounds()), applied.channels[1].surface.read_region(applied.bounds())); + } + + #[test] + fn filter_preview_overlay_matches_quick_mask_and_mixed_color_views() { + for quick_mask in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.session + .edit("Fixture", |doc, active| { + if quick_mask { + doc.quick_mask = + Some(photocraft_doc::AlphaChannel::new("Quick Mask", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8))); + } + for y in 0..16 { + for x in 0..8 { + if let Some(mask) = &mut doc.quick_mask { + mask.surface.write_pixel(x, y, &[1.0]); + } else { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + if !quick_mask { + let view = &mut app.session.active_mut().unwrap().channel_view; + view.target = photocraft_engine::channel_cmds::ChannelTarget::Color(0); + view.set_color_visible(2, false); + } + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let st = app.session.active().unwrap(); + let expected = crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(image.pixels, expected, "quick_mask={quick_mask}"); + delta.clear(); + } + } + /// A CPU-only app must be able to generate the actual rotation preview without a /// GPU adapter; checking the result also verifies Preview off and document integrity. #[test] diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index e4642d946d..d7a8b190f5 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -222,7 +222,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Mask and channel views are drawn over the canvas: show the same live document, // otherwise an opaque grayscale mask hides the stroke until it is committed (#2078). // The plain-composite path above needs no preview work. - let (doc, preview_key) = crate::canvas::display_doc(app, idx); + let (doc, preview_key) = crate::canvas::filter_channel_preview(app, idx, ctx).unwrap_or_else(|| crate::canvas::display_doc(app, idx)); // Interface ▸ Show Channels in Color tints single colour channel views on the canvas too, // not just the panel thumbnails (#2385). The view key carries the flag, so toggling it // rebuilds the texture. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73f3357a8c..7d36f29756 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -593,6 +593,7 @@ mod tests { revision: st.revision, dialog: id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: serde_json::json!({"radius": 4.0}), k: 1, diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 29fc436e0f..8f588fbc99 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -745,6 +745,19 @@ pub fn preview_document_with( params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { + preview_document_for_target(doc, active, command, params, k, cancel, photocraft_engine::channel_cmds::ChannelTarget::Composite) +} + +/// Run the preview with the same pixel target as the final command, including individual channels. +pub(crate) fn preview_document_for_target( + doc: &Document, + active: Option, + command: &str, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, + target: photocraft_engine::channel_cmds::ChannelTarget, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -759,6 +772,7 @@ pub fn preview_document_with( if let Some(id) = active { s.select_layer(id).ok()?; } + s.active_mut()?.channel_view.target = target; let mut p = params.clone(); if run_k > 1 && let Some(o) = p.as_object_mut() @@ -802,6 +816,7 @@ pub struct FilterPreviewKey { pub revision: u64, pub dialog: u64, pub active: Option, + pub target: photocraft_engine::channel_cmds::ChannelTarget, pub command: String, pub params: Value, pub k: u32, diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index bf2cb47b58..f2b8f89561 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -54,6 +54,12 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let t = Tokens::get(ui.ctx()); let cache_id = egui::Id::new(("filter-preview-ui", dialog)); let mut cache: Cache = ui.ctx().data_mut(|d| std::mem::take(d.get_temp_mut_or_default::(cache_id))); + // Channel visibility and the active mask can change without a document revision. Both the + // original (press-and-hold) and filtered textures must follow the canvas's current view. + let view = (st.channel_view.clone(), app.session.prefs().interface.show_channels_in_color, st.active_layer); + if cache.view.as_ref() != Some(&view) { + cache = Cache { view: Some(view), ..Default::default() }; + } // Unfiltered proxy: built once per document revision and factor (cheap at the proxy factor). let orig_key = TexKey::Original(doc_id, revision, base_k); @@ -201,18 +207,18 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { /// A composite as monitor pixels, matching the canvas (`canvas::display_image`). fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { - // Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution - // previews off the proxy is the full document. The view maps through UV fractions, so a reduced - // texture still lines up. - let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); - let reduced; - let doc = if k > 1 { - reduced = crate::proxy::proxy_document(doc, k); - &reduced - } else { - doc - }; - let buf = photocraft_compose::flatten(doc); + // Reduce only the display texture when a full-resolution result exceeds the device limit. + // The cached document and the window's document-pixel zoom coordinates stay at full size. + let factor = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); + let (width, height) = (doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); + if let Some(st) = app.session.active() + && let Some(pixels) = + crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, app.session.prefs().interface.show_channels_in_color, st.active_layer) + { + let image = egui::ColorImage::new([width as usize, height as usize], pixels); + return ctx.load_texture(name, image, egui::TextureOptions::LINEAR); + } + let buf = if factor == 1 { photocraft_compose::flatten(doc) } else { photocraft_compose::render_reduced(doc, width, height) }; let (display, _) = crate::canvas::canvas_display(app, doc, None); let img = crate::canvas::display_image(display.as_deref(), &buf); ctx.load_texture(name, img, egui::TextureOptions::LINEAR) @@ -244,12 +250,111 @@ struct CachedTex { struct Cache { filtered: Option, original: Option, + view: Option<(photocraft_engine::channel_cmds::ChannelView, bool, Option)>, } #[cfg(test)] mod tests { use super::*; + #[test] + fn full_resolution_preview_textures_respect_the_device_limit() { + let ctx = egui::Context::default(); + let limit = ctx.input(|i| i.max_texture_side) as u32; + let width = limit + 2; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": 2})).unwrap(); + for alpha in [false, true] { + if alpha { + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, (width / 2) as i32, 2), &[1.0]); + Ok(()) + }) + .unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let texture = upload(&ctx, &app, "full-resolution-preview", doc); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + assert_eq!(texture.size(), [width.div_ceil(2) as usize, 1]); + assert!(texture.size().into_iter().all(|side| side <= limit as usize)); + assert_eq!(doc.size.width, width, "display reduction must not resize the filter result"); + assert_eq!(pixels[0], Color32::WHITE); + assert_eq!(*pixels.last().unwrap(), if alpha { Color32::BLACK } else { Color32::WHITE }); + } + } + + #[test] + fn uploaded_preview_shows_the_filtered_alpha_at_every_depth_and_proxy_scale() { + use photocraft_engine::channel_cmds::ChannelTarget; + for depth in [8, 16, 32] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 16), &[1.0]); + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + for k in [1, 2, 4] { + let preview = crate::filter_dialog::preview_document_for_target( + &st.doc, + st.active_layer, + "filter.blur.gaussianBlur", + &json!({"radius": 2.0}), + k, + None, + ChannelTarget::Alpha(0), + ) + .unwrap(); + let ctx = egui::Context::default(); + let texture = upload(&ctx, &app, "alpha-preview", &preview); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + let expected = crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(pixels, expected, "depth {depth}, factor {k}"); + assert_ne!(pixels[0], pixels[pixels.len() - 1], "the alpha edge is visible, not the white RGB layer"); + assert!(pixels.iter().any(|p| p.r() > 0 && p.r() < 255), "the blurred edge is visible"); + } + } + } + + #[test] + fn original_texture_refreshes_when_the_channel_view_changes_without_an_edit() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("channel.new", json!({"name": "White", "fill": "white"})).unwrap(); + app.run("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let id = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let mut fields = app.ui.dialogs.last().unwrap().fields.clone(); + let ctx = egui::Context::default(); + let cache_id = egui::Id::new(("filter-preview-ui", id)); + ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)).textures_delta.clear(); + let before = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + assert_eq!(app.session.active().unwrap().revision, revision); + let mut output = ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)); + let after = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + let pixels = output.textures_delta.set.get(&after).map(|delta| { + let egui::ImageData::Color(image) = &delta[0].image; + image.pixels.clone() + }); + output.textures_delta.clear(); + assert_ne!(before, after, "press-and-hold must not reuse the old channel"); + assert!(pixels.unwrap().iter().all(|p| *p == Color32::BLACK)); + } + #[test] fn zoom_steps_walk_the_levels_and_stay_in_bounds() { assert_eq!(step_zoom(1.0, true), 2.0); diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 2704f8a6ac..48f29400fb 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -116,6 +116,7 @@ mod tests { revision, dialog, active: None, + target: Default::default(), command: "image.mode.indexedColor".into(), params: json!({"colors": colors}), k: 1, From 90c42f5555b1ff1cbc708bb6f8bbf28c7387895d Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:12:35 -0500 Subject: [PATCH 6/7] Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image.imageSize takes percent (both sides; an explicit side wins). - actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller. - .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs, imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108. - Panel labels conditional steps as 'If cond → then / else'. --- crates/engine/src/actions_cmds.rs | 2 +- crates/engine/src/actions_edit_cmds.rs | 189 +++++++++++++++++++++++++ crates/engine/src/atn_cmds.rs | 137 ++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 76 +++++++++- crates/engine/src/image_cmds.rs | 12 +- crates/ui-egui/src/actions.rs | 5 + docs/control-protocol.md | 5 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 11 +- docs/target-app-parity.md | 6 +- 10 files changed, 418 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index edadde2117..3ee242d7d6 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -353,7 +353,7 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio /// Nested playback returns a structured failure even though the command itself succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. pub fn nested_failure(id: &str, value: &Value) -> Option { - (id == "actions.play").then(|| value.get("failed")).flatten().map(|failure| { + (id == "actions.play" || id == "actions.conditional").then(|| value.get("failed")).flatten().map(|failure| { format!( "action `{}` step {} failed: {}", value.get("action").and_then(Value::as_str).unwrap_or(""), diff --git a/crates/engine/src/actions_edit_cmds.rs b/crates/engine/src/actions_edit_cmds.rs index c7cde6869f..1f9cce3431 100644 --- a/crates/engine/src/actions_edit_cmds.rs +++ b/crates/engine/src/actions_edit_cmds.rs @@ -126,6 +126,123 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The conditions of Photoshop's Insert Conditional, by their string ids. +pub const CONDITIONS: &[&str] = &[ + "always", + "never", + "isOpen", + "landscape", + "square", + "portrait", + "modeIsRGB", + "modeIsCMYK", + "modeIsGrayscale", + "modeIsLab", + "modeIsBitmap", + "modeIsIndexed", + "modeIsDuotone", + "modeIsMultichannel", + "depth8", + "depth16", + "depth32", + "unsaved", + "hasSelection", + "hasLayers", + "hasAlphaChannels", + "isBackground", + "isPixelLayer", + "isAdjustmentLayer", + "isShapeLayer", + "isTypeLayer", + "isSmartObject", + "isLayerGroup", + "isLocked", + "isVisible", + "hasPixelMask", + "hasVectorMask", + "hasEffects", +]; + +/// Whether `condition` holds for the active document and layer. Layer conditions are false +/// without a document or an active layer; `isOpen` is whether a document is open. +pub fn evaluate(s: &Session, condition: &str) -> Result { + use photocraft_color::ColorMode; + use photocraft_doc::LayerContent; + if !CONDITIONS.contains(&condition) { + return Err(bad("actions.conditional", format!("unknown condition `{condition}` ({})", CONDITIONS.join("|")))); + } + let Some(d) = s.active() else { return Ok(condition == "always") }; + let doc = &d.doc; + let layer = d.active_layer.and_then(|id| doc.layer(id)); + let (w, h) = (doc.size.width, doc.size.height); + Ok(match condition { + "always" | "isOpen" => true, + "never" => false, + "landscape" => w > h, + "square" => w == h, + "portrait" => h > w, + "modeIsRGB" => doc.mode == ColorMode::Rgb, + "modeIsCMYK" => doc.mode == ColorMode::Cmyk, + "modeIsGrayscale" => doc.mode == ColorMode::Grayscale, + "modeIsLab" => doc.mode == ColorMode::Lab, + "modeIsBitmap" => doc.mode == ColorMode::Bitmap, + "modeIsIndexed" => doc.mode == ColorMode::Indexed, + "modeIsDuotone" => doc.mode == ColorMode::Duotone, + "modeIsMultichannel" => doc.mode == ColorMode::Multichannel, + "depth8" => doc.depth == photocraft_color::SampleType::U8, + "depth16" => doc.depth == photocraft_color::SampleType::U16, + "depth32" => doc.depth == photocraft_color::SampleType::F32, + "unsaved" => d.is_dirty(), + "hasSelection" => doc.selection.is_some(), + // More than a lone Background: any layer that isn't the background. + "hasLayers" => doc.walk().iter().any(|(_, _, l)| !doc.is_background(l.id)), + "hasAlphaChannels" => !doc.channels.is_empty(), + "isBackground" => layer.is_some_and(|l| doc.is_background(l.id)), + "isPixelLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !doc.is_background(l.id)), + "isAdjustmentLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Adjustment(_))), + "isShapeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Shape(_))), + "isTypeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Text(_))), + "isSmartObject" => layer.is_some_and(|l| matches!(l.content, LayerContent::Smart(_))), + "isLayerGroup" => layer.is_some_and(|l| l.is_group()), + "isLocked" => layer.is_some_and(|l| l.locks.all || l.locks.pixels || l.locks.position || l.locks.transparency), + "isVisible" => layer.is_some_and(|l| l.visible), + "hasPixelMask" => layer.is_some_and(|l| l.mask.is_some()), + "hasVectorMask" => layer.is_some_and(|l| l.vector_mask.is_some()), + "hasEffects" => layer.is_some_and(|l| !l.effects.items.is_empty()), + _ => false, + }) +} + +/// Photoshop's Insert Conditional as a step: test the condition, play `then` or `else`. +fn conditional(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.conditional"; + let condition = match p.get("if") { + Some(Value::String(c)) => c.clone(), + _ => return Err(bad(cmd, format!("needs `if`, one of {}", CONDITIONS.join("|")))), + }; + let branch = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.clone())), + Some(Value::String(_)) => Ok(None), + Some(_) => Err(bad(cmd, format!("`{key}` must be an action name or null"))), + } + }; + let (then, otherwise) = (branch("then")?, branch("else")?); + let holds = evaluate(s, &condition)?; + let played = if holds { then } else { otherwise }; + let mut out = json!({"if": condition, "holds": holds, "played": played}); + if let Some(name) = played { + let r = s.execute("actions.play", json!({"action": name}))?; + if let Some(f) = r.get("failed") { + out["failed"] = f.clone(); + } + out["ran"] = r.get("ran").cloned().unwrap_or(json!(0)); + out["action"] = json!(name); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -148,6 +265,16 @@ pub fn specs() -> Vec { run: dialog, journal: false, }, + CommandSpec { + id: "actions.conditional", + label: "Conditional", + menu: &[], + shortcut: None, + params: r##"{"if":condition, "then":action name|null, "else":action name|null} → {if, holds, played, ran?, failed?}. Photoshop's Insert Conditional: conditions always|never|isOpen|landscape|square|portrait|modeIsRGB|modeIsCMYK|modeIsGrayscale|modeIsLab|modeIsBitmap|modeIsIndexed|modeIsDuotone|modeIsMultichannel|depth8|depth16|depth32|unsaved|hasSelection|hasLayers|hasAlphaChannels|isBackground|isPixelLayer|isAdjustmentLayer|isShapeLayer|isTypeLayer|isSmartObject|isLayerGroup|isLocked|isVisible|hasPixelMask|hasVectorMask|hasEffects (layer conditions test the active layer). The branch plays as actions.play does; its failure stops the calling action."##, + enabled: always, + run: conditional, + journal: true, + }, CommandSpec { id: "actions.assignSet", label: "Move to Set", @@ -223,6 +350,68 @@ mod tests { assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); } + #[test] + fn conditional_tests_the_document_and_plays_a_branch() { + let mut s = session_with(&[]); + s.actions.list.push(Action::new("Yes", vec![("layer.new.layer".into(), json!({"name": "yes"}))])); + s.actions.list.push(Action::new("No", vec![("layer.new.layer".into(), json!({"name": "no"}))])); + let names = |s: &Session| s.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).collect::>(); + // 8 × 8: square, not landscape; one Background: no layers yet. + for (cond, expect) in [ + ("square", true), + ("landscape", false), + ("portrait", false), + ("modeIsRGB", true), + ("modeIsCMYK", false), + ("depth8", true), + ("hasLayers", false), + ("hasSelection", false), + ("isBackground", true), + ("isPixelLayer", false), + ("hasEffects", false), + ("always", true), + ("never", false), + ("isOpen", true), + ] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "square", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str(), r["ran"].as_u64()), (Some(true), Some("Yes"), Some(1))); + assert_eq!(names(&s).last().map(String::as_str), Some("yes")); + for (cond, expect) in [("hasLayers", true), ("isPixelLayer", true), ("isBackground", false), ("isVisible", true)] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str()), (Some(false), Some("No"))); + assert_eq!(names(&s).last().map(String::as_str), Some("no")); + // No branch for the outcome: nothing plays, no error. + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes"})).unwrap(); + assert!(r["played"].is_null()); + // A failing branch reports like a nested Play, and stops a calling action. + s.actions.list.push(Action::new("Bad", vec![("layer.delete".into(), json!({"layer": 999}))])); + let r = s.execute("actions.conditional", json!({"if": "always", "then": "Bad"})).unwrap(); + assert!(r["failed"].is_object(), "{r}"); + assert!(crate::actions_cmds::nested_failure("actions.conditional", &r).is_some()); + s.actions.list.push(Action::new( + "Caller", + vec![("actions.conditional".into(), json!({"if": "always", "then": "Bad"})), ("layer.new.layer".into(), json!({"name": "after"}))], + )); + let played = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert_eq!(played["failed"]["step"], 0, "{played}"); + assert_ne!(names(&s).last().map(String::as_str), Some("after")); + // Without a document only always/never answer; layer conditions are false. + let t = Session::new(); + assert!(evaluate(&t, "always").unwrap()); + assert!(!evaluate(&t, "isOpen").unwrap()); + assert!(!evaluate(&t, "isBackground").unwrap()); + for p in [json!({}), json!({"if": "sometimes"}), json!({"if": 3}), json!({"if": "always", "then": 5}), json!({"if": "always", "then": "Nope"})] { + assert!( + s.execute("actions.conditional", p.clone()).is_err() || s.execute("actions.conditional", p.clone()).unwrap().get("failed").is_some(), + "{p}" + ); + } + } + #[test] fn assign_set_moves_an_action_between_folders() { let mut s = session_with(&["select.all"]); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index fa9e649c0b..e5c8e74674 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -164,8 +164,9 @@ fn class(c: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: code(c) } } -/// The pixel unit of a `UntF` value. +/// The pixel and percent units of a `UntF` value. const PX: [u8; 4] = *b"#Pxl"; +const PCT: [u8; 4] = *b"#Prc"; fn px(v: f64) -> D { D::UnitFloat { unit: PX, value: v } @@ -1338,6 +1339,69 @@ fn clear_to(id: &str, p: &Value) -> Option { } } +/// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale. +fn place_from(d: &Descriptor) -> Mapped { + let Some(path) = d.get("null").and_then(path_text) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"path": path}); + if let Some(w) = get_num(d, "Wdth") { + p["scale"] = json!(w); + } + step(if get_bool(d, "Lnkd") == Some(true) { "file.placeLinked" } else { "file.placeEmbedded" }, p) +} + +fn place_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", path_value(jstr(p, "path")?)).with("FTcs", enm("QCSt", "Qcsa")); + if let Some(s) = jnum(p, "scale") { + d = d.with("Wdth", pct(s)).with("Hght", pct(s)); + } + Some(d.with("Lnkd", D::Boolean(id == "file.placeLinked")).with("Antl", D::Boolean(true))) +} + +/// `open`: File › Open of a recorded path. +fn open_from(d: &Descriptor) -> Mapped { + match d.get("null").and_then(path_text) { + Some(path) => step("file.openAs", json!({"path": path})), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn open_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", path_value(jstr(p, "path")?))) +} + +/// Photoshop's four-character condition codes that differ from the string ids. +const CONDITION_CODES: &[(&str, &str)] = &[("Lyrs", "hasLayers")]; + +/// `conditional`: Insert Conditional, with its Then and Else actions by name. +fn conditional_from(d: &Descriptor) -> Mapped { + let Some(c) = get_enum(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let condition = lookup(CONDITION_CODES, &c).unwrap_or(c.as_str()); + if !crate::actions_edit_cmds::CONDITIONS.contains(&condition) { + return Mapped::Unsupported { harmless: false }; + } + let branch = |key: &str| match get_ref(d, key).map(target) { + Some(Target::Name(n)) => Some(n), + _ => None, + }; + step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) +} + +fn conditional_to(_: &str, p: &Value) -> Option { + let condition = jstr(p, "if")?; + let code = lookup_back(CONDITION_CODES, condition).unwrap_or(condition); + let mut d = desc("null").with("null", enm("Cndt", code)); + for key in ["then", "else"] { + if let Some(name) = jstr(p, key) { + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + d = d.with(key, D::Reference(items)); + } + } + Some(d) +} + /// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } @@ -1712,9 +1776,36 @@ fn play_to(_: &str, p: &Value) -> Option { } fn fill_from(d: &Descriptor) -> Mapped { - if d.get("From").is_some() { - // The Paint Bucket tool's click, not Edit › Fill. - return Mapped::Unsupported { harmless: false }; + if let Some(from) = get_obj(d, "From") { + // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas + // (not replayable without the document size). + let in_px = |k: &str| matches!(from.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(x), Some(y)) = (get_num(from, "Hrzn"), get_num(from, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + return Mapped::Unsupported { harmless: false }; + } + let mut p = json!({"x": x.round(), "y": y.round()}); + match get_enum(d, "Usng").as_deref() { + Some("FrgC") | None => {} + Some("Pttn") => p["contents"] = json!("pattern"), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + if let Some(t) = get_num(d, "Tlrn") { + p["tolerance"] = json!(t); + } + if let Some(c) = get_bool(d, "Cntg") { + p["contiguous"] = json!(c); + } + if let Some(a) = get_bool(d, "AntA") { + p["antiAlias"] = json!(a); + } + if let Some(m) = get_bool(d, "Mrgd") { + p["sampleAllLayers"] = json!(m); + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + return step("paint.bucket", p); } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); @@ -1739,7 +1830,20 @@ fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } -fn fill_to(_: &str, p: &Value) -> Option { +fn fill_to(id: &str, p: &Value) -> Option { + if id == "paint.bucket" { + let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); + d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); + d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(m) = jbool(p, "sampleAllLayers") { + d = d.with("Mrgd", D::Boolean(m)); + } + return Some(d); + } let contents = jstr(p, "contents").unwrap_or("color"); let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); if contents == "color" { @@ -1935,16 +2039,25 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut percent: Option = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { p.insert(param.into(), value.round().into()); } - // Percent or physical units need the document's size: not replayable headlessly. + // Percent scales both sides at playback; two different percentages, or physical + // units, need the document's size and resolution and stay unsupported. + Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, + _ => percent = Some(*value), + }, Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if let Some(pc) = percent { + p.insert("percent".into(), pc.into()); + } if let Some(r) = get_num(d, "Rslt") { p.insert("resolution".into(), r.into()); } @@ -1955,7 +2068,7 @@ fn image_size_from(d: &Descriptor) -> Mapped { } None => return Mapped::Unsupported { harmless: false }, } - } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") { p.insert("resample".into(), "none".into()); } if p.is_empty() { @@ -1966,6 +2079,9 @@ fn image_size_from(d: &Descriptor) -> Mapped { fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(pc) = jnum(p, "percent") { + d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); + } if let Some(w) = jnum(p, "width") { d = d.with("Wdth", px(w)); } @@ -2459,7 +2575,7 @@ const EVENTS: &[Event] = &[ ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), // Edit. - Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), @@ -2494,6 +2610,9 @@ const EVENTS: &[Event] = &[ // File and actions. Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, + Event { code: "Plc ", name: "placeEvent", label: "Place", ids: &["file.placeEmbedded", "file.placeLinked"], from: place_from, to: place_to }, + Event { code: "Opn ", name: "open", label: "Open", ids: &["file.openAs"], from: open_from, to: open_to }, + Event { code: "", name: "conditional", label: "If", ids: &["actions.conditional"], from: conditional_from, to: conditional_to }, ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, @@ -2630,7 +2749,7 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } let mut params = params.clone(); - if id == "actions.play" && params.get("set").is_none() { + if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } let (e, descriptor) = event_and_descriptor(id, ¶ms)?; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 6d6f7bb68e..fc73a18412 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -286,6 +286,13 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.convertToSmartObject", json!({})), ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), + ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), + ("file.placeLinked", json!({"path": "/tmp/logo.png"})), + ("file.openAs", json!({"path": "/tmp/a.psd"})), + ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), + ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; @@ -303,7 +310,7 @@ fn every_mapped_command_exports_and_imports_back() { assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); normalise(&mut back); - if *id == "actions.play" { + if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); } assert_eq!(back, *params, "{id}"); @@ -401,10 +408,15 @@ fn every_mapped_command_exports_and_imports_back() { #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { - // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a + // move in points, an unknown adjustment class. let items = vec![ - item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), - item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))), + item( + "Fl ", + "Fill", + Some(desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))).with("Usng", enm("FlCn", "FrgC"))), + ), item( "move", "Move", @@ -662,6 +674,62 @@ fn remaining_events_import_in_their_photoshop_shapes() { assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); assert_eq!(imp.unsupported.len(), 2); + // The frequency-separation set's own conditional, a percent Image Size, a Paint Bucket + // click in pixels (percent stays unsupported) and a Place. + let cond = desc("null") + .with("null", enm("Cndt", "Lyrs")) + .with( + "then", + D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-Layers") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("FS") }, + ]), + ) + .with("else", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-No-Layers") }])); + let size = desc("null") + .with("Wdth", pct(200.0)) + .with("scaleStyles", D::Boolean(true)) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "bicubicAutomatic")); + let bucket_px = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")) + .with("Cntg", D::Boolean(false)); + let bucket_pct = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")); + let place = desc("null") + .with("null", path_value("/Users/me/logo.png")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(false)); + let items = vec![ + item("conditional", "If", Some(cond)), + item("imageSize", "Image Size", Some(size)), + item("fill", "Fill", Some(bucket_px)), + item("fill", "Fill", Some(bucket_pct)), + item("placeEvent", "Place", Some(place)), + item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Rest"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let rest = import(&atn::write(&file).unwrap()).unwrap(); + let rest_steps = &rest.actions[0].steps; + let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); + assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); + assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); + assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); + assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); + // The Photoshop path record round-trips and rejects other shapes. assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png")); assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 84474d57d1..d26d87e517 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -165,8 +165,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); - let pw = p.get("width").and_then(Value::as_f64); - let ph = p.get("height").and_then(Value::as_f64); + let mut pw = p.get("width").and_then(Value::as_f64); + let mut ph = p.get("height").and_then(Value::as_f64); + // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. + if let Some(pc) = p.get("percent") { + let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; + pw = pw.or(Some(ow as f64 * pc / 100.0)); + ph = ph.or(Some(oh as f64 * pc / 100.0)); + } // Missing dimension keeps the aspect ratio. let (nw, nh) = match (pw, ph) { (Some(w), Some(h)) => (w, h), @@ -713,7 +719,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 997770b5f6..7902a39bba 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -248,6 +248,11 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "actions.conditional" { + // "If hasLayers: FS8-Layers / FS8-No-Layers" + let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); + let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); + format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b775728aff..f23745efdb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -535,7 +535,10 @@ step with its dialog on opens that command's dialog seeded with the recorded val the play result carries `paused: {step, id, dialog}`, OK runs the dialog's values and continues from the next step, Cancel stops the action. Agents (`automation_input`) play headlessly with the recorded values. The panel menu's **Insert Stop…** adds an `actions.stopMessage` step after the -selected step (`actions.insert` takes any command id and params). +selected step (`actions.insert` takes any command id and params). `actions.conditional {if, then, +else}` is Photoshop's Insert Conditional as a step: it tests the document or active layer +(`always`, `landscape`, `modeIsRGB`, `hasLayers`, `isBackground`, `hasPixelMask`… see its params) +and plays the matching action by name, failing the caller when that action fails. Layer-panel **Quick Export as PNG** exports every selected layer as a separate PNG. A single Save dialog chooses the first filename; remaining files are numbered siblings with sanitized diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bc892c0ed3..d6e3bef284 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (105 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | diff --git a/docs/gaps.md b/docs/gaps.md index c5e1172415..d18a8655c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,12 +89,13 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - mapped events (Place, Image Size in percent, paths, conditionals, Generative Fill…) load as - kept Photoshop steps that fail at playback by name. + mapped events (paths, scripts, Generative Fill, offsets in points…) load as kept Photoshop + steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 105 Photoshop events map to commands both ways (Transform, - Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask and polygon selections - since the third PR), Play and Stop steps included; layer steps replay by layer name in any + and `actions.load`/`actions.save`; 108 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, + Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third + and fourth PRs), Play, Stop and conditional steps included; layer steps replay by layer name in any document. The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae076b3208..b5e8dd8a04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (108 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 105 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (105 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | From 67c90e307f1dbc4d918423e4a7a8932f73afe0eb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:12:59 +1100 Subject: [PATCH 7/7] Quantize the colour field when Only Web Colors is on. (#4138) * Quantize the colour field when Only Web Colors is on. The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible. Fixes #4107 * Only Web Colors: draw the field and slider in flat web-safe bands The coarse interpolated mesh blended between snapped corners, so it showed a smooth low-resolution gradient rather than bands. Draw flat cells sampled at their centres instead, and test that every cell is a single web-safe colour. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/color_picker_ui.rs | 56 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index e9729eab67..30752c462f 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -400,6 +400,36 @@ fn grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh m } +/// Cells per side of the banded Only Web Colors field: fine enough that the band edges follow the +/// colour model, coarse enough to stay cheap to draw each frame. +const WEB_CELLS: usize = 64; + +/// Snap each channel to the web-safe ramp (00, 33, 66, 99, CC, FF). +fn web_safe(c: [f32; 3]) -> [f32; 3] { + c.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0) +} + +/// Like [`grid_mesh`], but every cell is one flat colour sampled at its centre, so a quantized +/// field shows hard bands instead of blending between the snapped corners. +fn flat_grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh { + let mut m = Mesh::default(); + let n = n.max(1); + let (cw, ch) = (rect.width() / n as f32, rect.height() / n as f32); + for j in 0..n { + for i in 0..n { + let c = c32(color((i as f32 + 0.5) / n as f32, (j as f32 + 0.5) / n as f32)); + let (x0, y0) = (rect.left() + i as f32 * cw, rect.top() + j as f32 * ch); + let a = m.vertices.len() as u32; + for (dx, dy) in [(0.0, 0.0), (cw, 0.0), (cw, ch), (0.0, ch)] { + m.colored_vertex(pos2(x0 + dx, y0 + dy), c); + } + m.add_triangle(a, a + 1, a + 2); + m.add_triangle(a, a + 2, a + 3); + } + } + m +} + /// Room for both component columns, including units, without overflowing the title's width; with /// the frame and title the window fits Photoshop's (about 534 x 376 points). pub(crate) const CONTENT_WIDTH: f32 = 488.0; @@ -469,7 +499,12 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let (fx, fy, fz) = locate(&mode, hsv, rgb, lab); // Colour field. let (field, resp) = ui.allocate_exact_size(vec2(FIELD, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y))); + let web_only = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); + // Only Web Colors bands the field to the 216 web-safe colours, as in Photoshop (#4107). + let mesh = |rect: Rect, color: &dyn Fn(f32, f32) -> [f32; 3]| { + if web_only { flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(color(x, y))) } else { grid_mesh(rect, 32, color) } + }; + ui.painter().add(mesh(field, &|x, y| field_color(&mode, fz, x, y))); ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); @@ -486,7 +521,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { } // Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop). let (strip, sresp) = ui.allocate_exact_size(vec2(18.0, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); + ui.painter().add(mesh(strip, &|_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); let sy = strip.top() + (1.0 - fz) * strip.height(); for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; @@ -767,6 +802,23 @@ mod tests { use super::*; + /// #4107: with Only Web Colors on, the field is drawn in flat web-safe bands. + #[test] + fn web_only_field_is_flat_web_safe_bands() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); + let m = flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(field_color("h", 0.3, x, y))); + let ramp = [0u8, 51, 102, 153, 204, 255]; + for v in &m.vertices { + assert!([v.color.r(), v.color.g(), v.color.b()].iter().all(|c| ramp.contains(c)), "{:?}", v.color); + } + // Each cell's four corners share one colour (no blending between bands). + for q in m.vertices.chunks(4) { + assert!(q.iter().all(|v| v.color == q[0].color)); + } + let distinct: std::collections::HashSet<_> = m.vertices.iter().map(|v| v.color).collect(); + assert!(distinct.len() > 1 && distinct.len() <= 216); + } + fn picker(color: &str) -> Harness<'static, Map> { let mut f = Map::new(); f.insert("__colorPicker".into(), json!("foreground"));