From 1d2ebfdc1f7dee57ec8d9a9a59602ebe8e6cf3de Mon Sep 17 00:00:00 2001 From: highperfocused Date: Thu, 15 Oct 2026 15:35:49 +0200 Subject: [PATCH 01/27] File Info: Photoshop field order and labels (#6125, #6126) --- crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ crates/ui-egui/src/view_cmds.rs | 37 +++++++++++++++++++++++++-- crates/ui-egui/src/view_cmds/tests.rs | 25 ++++++++++++++++++ 22 files changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..7f6a3f7320 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2792,6 +2792,9 @@ cameraData Lens Objektiv Cooling80 Ochlazovací 80 Cooling82 Ochlazovací 82 Copyright Status Stav autorských práv + Copyright Info URL URL informace o autorských právech + Copyright Notice Oznámení autorských práv + Document Title Název dokumentu Copyrighted Chráněno autorským právem Correct C A Korigovat chromatickou aberaci Correct Distortion Korigovat zkreslení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..8dda6bd821 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2812,6 +2812,9 @@ curves Smooth Glätten # Document Info and color profiles Author Title Position des Urhebers Copyright Status Urheberrechtsstatus + Copyright Info URL URL für Urheberrechtsinfo + Copyright Notice Urheberrechtsvermerk + Document Title Dokumenttitel Unknown Unbekannt Copyrighted Urheberrechtlich geschützt Public Domain Gemeinfrei diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..7c65c7eda9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -995,6 +995,8 @@ Keywords Λέξεις-κλειδιά Copyright Info URL Σύνδεσμος πληροφοριών πνευματικών δικαιωμάτων Copyright Status Κατάσταση πνευματικών δικαιωμάτων + Copyright Notice Σημείωση πνευματικών δικαιωμάτων + Document Title Τίτλος εγγράφου Copyrighted Προστατεύεται από πνευματικά δικαιώματα Public Domain Κοινό κτήμα Unknown Άγνωστο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..9d09393af1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2990,6 +2990,8 @@ curves Smooth Suavizar Keywords Palabras clave Copyright Info URL URL de información de copyright Copyright Status Estado del copyright + Copyright Notice Aviso de copyright + Document Title Título del documento Copyrighted Con copyright Public Domain Dominio público Unknown Desconocido diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..2414e1818e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2431,6 +2431,8 @@ Keywords Mots-clés Copyright Info URL URL des informations de copyright Copyright Status Statut du copyright + Copyright Notice Mention de copyright + Document Title Titre du document Copyrighted Protégé par copyright Public Domain Domaine public Unknown Inconnu diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..2a1741142c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3140,6 +3140,8 @@ cameraRaw Yellow Sárga Convert to Profile Átalakítás profilra Convert to srgb Átalakítás sRGB-re Copyright Status Szerzői jogi állapot + Copyright Notice Szerzői jogi értesítés + Document Title Dokumentum címe Corner radius Sarokkerekítés sugara Correct C A Kromatikus aberráció javítása Correct Distortion Torzítás javítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..6cfa5a068a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2422,6 +2422,8 @@ Keywords Kata Kunci Copyright Info URL URL Info Hak Cipta Copyright Status Status Hak Cipta + Copyright Notice Pemberitahuan Hak Cipta + Document Title Judul Dokumen Copyrighted Berhak Cipta Public Domain Domain Publik Unknown Tidak Diketahui diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..7e26485688 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2982,6 +2982,8 @@ curves Smooth Leviga Keywords Parole chiave Copyright Info URL URL info copyright Copyright Status Stato copyright + Copyright Notice Avviso sul copyright + Document Title Titolo documento Copyrighted Protetto da copyright Public Domain Pubblico dominio Unknown Sconosciuto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..7ef6f1ef1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3043,6 +3043,8 @@ fontWeight Italic 斜体 Cooling80 寒色フィルター (80) Cooling82 寒色フィルター (82) Copyright Status 著作権の状態 + Copyright Notice 著作権表示 + Document Title ドキュメントタイトル Copyrighted 著作権あり Corners 角 Correct C A 色収差を補正 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..c679116cad 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -969,6 +969,9 @@ Copper 구리 Copyright 저작권 Copyright and Contact Info 저작권 및 연락처 정보 + Copyright Info URL 저작권 정보 URL + Copyright Notice 저작권 고지 + Document Title 문서 제목 Corner Crop Marks 모서리 자르기 표시 Corner radius 모서리 반경 Correction 보정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..0a1eed875d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2831,6 +2831,8 @@ cameraRaw Color Kleur Keywords Trefwoorden Copyright Info URL URL copyrightinformatie Copyright Status Copyrightstatus + Copyright Notice Copyrightvermelding + Document Title Documenttitel Copyrighted Auteursrechtelijk beschermd Public Domain Publiek domein Unknown Onbekend diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..044b97adad 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2454,6 +2454,8 @@ Keywords Słowa kluczowe Copyright Info URL Adres URL informacji o prawach autorskich Copyright Status Status praw autorskich + Copyright Notice Informacja o prawach autorskich + Document Title Tytuł dokumentu Copyrighted Chronione prawem autorskim Public Domain Domena publiczna Unknown Nieznany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..298872a93e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2426,6 +2426,8 @@ Keywords Palavras-chave Copyright Info URL URL das informações de direitos autorais Copyright Status Status dos direitos autorais + Copyright Notice Aviso de copyright + Document Title Título do documento Copyrighted Protegido por direitos autorais Public Domain Domínio público Unknown Desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..16a41b2ed4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1339,6 +1339,8 @@ Keywords Ключевые слова Copyright Info URL URL сведений об авторских правах Copyright Status Статус авторских прав + Copyright Notice Уведомление об авторских правах + Document Title Название документа Copyrighted Охраняется авторским правом Public Domain Общественное достояние Unknown Неизвестно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..8a0b707fc5 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2408,6 +2408,8 @@ Keywords Ključne reči Copyright Info URL URL informacija o autorskim pravima Copyright Status Status autorskih prava + Copyright Notice Obaveštenje o autorskim pravima + Document Title Naslov dokumenta Copyrighted Zaštićeno autorskim pravima Public Domain Javno dobro Unknown Nepoznato diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..b0581e1d5b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2801,6 +2801,8 @@ cameraRaw Color Renk Keywords Anahtar Kelimeler Copyright Info URL Telif Hakkı Bilgisi URL'si Copyright Status Telif Hakkı Durumu + Copyright Notice Telif hakkı bildirimi + Document Title Belge başlığı Copyrighted Telif hakkıyla korunuyor Public Domain Kamu Alanı Unknown Bilinmiyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..d391e5e81b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1314,6 +1314,8 @@ Keywords Ключові слова Copyright Info URL URL інформації про авторське право Copyright Status Статус авторського права + Copyright Notice Застереження про авторські права + Document Title Назва документа Copyrighted Захищено авторським правом Public Domain Суспільне надбання Unknown Невідомо diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..b253245199 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2981,6 +2981,9 @@ cameraData Lens Ống kính Cooling80 Lạnh80 Cooling82 Lạnh82 Copyright Status Trạng thái bản quyền + Copyright Info URL URL thông tin bản quyền + Copyright Notice Thông báo bản quyền + Document Title Tiêu đề tài liệu Copyrighted Được bảo vệ bản quyền Correct C A Hiệu chỉnh C A Correct Distortion Hiệu chỉnh độ méo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..d255190bcf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -997,6 +997,9 @@ Copper 铜色 Copyright 版权 Copyright and Contact Info 版权与联系信息 + Copyright Info URL 版权信息 URL + Copyright Notice 版权声明 + Document Title 文档标题 Corner Crop Marks 角落裁剪标记 Corner radius 圆角半径 Correction 校正 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..2d874322c4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1001,6 +1001,9 @@ Copper 銅色 Copyright 版權 Copyright and Contact Info 版權與聯絡資訊 + Copyright Info URL 版權資訊 URL + Copyright Notice 版權聲明 + Document Title 文件標題 Corner Crop Marks 角落裁切標記 Corner radius 圓角半徑 Correction 校正 diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..6d1a555ab3 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1059,6 +1059,23 @@ mod form_reset_tests { } } +/// Display order of a `__form` dialog's field keys. `__order` lists them the way Photoshop +/// shows them (e.g. File Info); keys it omits keep their catalogue order after it. +pub fn form_keys(f: &Map) -> Vec { + let mut keys: Vec = f + .get("__order") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|k| !k.starts_with("__") && f.contains_key(*k)) + .map(str::to_string) + .collect(); + let rest: Vec = f.keys().filter(|k| !k.starts_with("__") && !keys.contains(k)).cloned().collect(); + keys.extend(rest); + keys +} + /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); @@ -1066,7 +1083,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { // come from `__labels` verbatim; everything else goes through the catalogue. let labels = f.get("__labels").cloned().unwrap_or(Value::Null); let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); - let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + let keys = form_keys(f); egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); @@ -1245,7 +1262,23 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); dialog( app, - json!({"title": info["title"], "author": info["author"], "authorTitle": info["authorTitle"], "description": info["description"], "keywords": kw, "copyright": info["copyright"], "copyrightStatus": info["copyrightStatus"], "copyrightUrl": info["copyrightUrl"], "__camera": info["camera"]}), + json!({ + "title": info["title"], + "author": info["author"], + "authorTitle": info["authorTitle"], + "description": info["description"], + "keywords": kw, + "copyrightStatus": info["copyrightStatus"], + "copyright": info["copyright"], + "copyrightUrl": info["copyrightUrl"], + "__order": ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"], + "__labels": { + "title": tl!("Document Title"), + "copyright": tl!("Copyright Notice"), + "copyrightUrl": tl!("Copyright Info URL"), + }, + "__camera": info["camera"] + }), json!({"copyrightStatus": ["unknown", "copyrighted", "publicDomain"]}), ) } diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..7036c018aa 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,28 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +#[test] +fn file_info_dialog_matches_photoshop_field_order_and_labels() { + crate::i18n::with_language(crate::i18n::Lang::EN, || { + let (mut app, ctx) = app_with(1); + let d = menu(&mut app, &ctx, "file.fileInfo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = app.ui.dialog_mut(d).unwrap().fields.clone(); + assert_eq!( + form_keys(&fields).iter().map(String::as_str).collect::>(), + ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"] + ); + let labels = fields["__labels"].as_object().unwrap(); + assert_eq!(labels["title"], "Document Title"); + assert_eq!(labels["copyright"], "Copyright Notice"); + assert_eq!(labels["copyrightUrl"], "Copyright Info URL"); + }); +} + +#[test] +fn form_keys_orders_by_order_then_catalogue() { + let f = json!({"zeta": "", "alpha": "", "mid": "", "__order": ["mid", "zeta", "ghost"]}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["mid", "zeta", "alpha"]); + let f = json!({"zeta": "", "alpha": ""}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["alpha", "zeta"]); +} From b4e0d2491dfe4e3493bb31e2e6123948de85fb48 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 19:24:17 +0200 Subject: [PATCH 02/27] macOS: Beep When Done plays the system alert sound (NSBeep) instead of a terminal bell (#6537) --- apps/photocraft/Cargo.toml | 5 +++-- apps/photocraft/src/services.rs | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5af663471..cb8fe0ef54 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -50,11 +50,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" # Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the +# system alert sound for Beep When Done (services.rs, NSBeep). Already in # Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..e0ab1c527f 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -574,10 +574,14 @@ fn platform_beep() { { let _ = winsafe::MessageBeep(winsafe::co::MBP::OK); } - #[cfg(not(target_os = "windows"))] + // The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal + // bell below is silent for an app started from the Finder or the Dock. + #[cfg(target_os = "macos")] + objc2_app_kit::NSBeep(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - // No beep API among this app's other platforms' dependencies: write the terminal bell - // (audible when launched from a terminal, which is the same cue macOS terminals use). + // No beep API among the other platforms' dependencies: write the terminal bell (audible + // when launched from a terminal). use std::io::Write as _; let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush()); } From 6511eed63db71ebd37aa2d4106925918c24ad9ae Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:03:31 +0200 Subject: [PATCH 03/27] Actions: Shift-click layer select in .atn imports as range select and exports back (#6784) --- crates/engine/src/atn_cmds.rs | 3 +++ crates/engine/src/atn_cmds/tests.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..504e53ecf8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1406,6 +1406,8 @@ fn select_from(d: &Descriptor) -> Mapped { match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), + // Shift-click: select everything between the active layer and this one. + Some("addToSelectionContinuous") => p["mode"] = json!("range"), _ => {} } step("layer.select", p) @@ -1449,6 +1451,7 @@ fn select_to(id: &str, p: &Value) -> Option { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")), + Some("range") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelectionContinuous")), Some(_) => return None, } d.with("MkVs", D::Boolean(false)) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..059efe78a3 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -237,6 +237,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setProps", json!({"layer": "Shadow", "visible": false})), ("layer.setLabelColor", json!({"color": "red"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), + ("layer.select", json!({"layer": "Shadow", "mode": "range"})), ("layer.selectTop", json!({})), ("layer.selectBelow", json!({})), ("layer.addBelowToSelection", json!({})), From 45e15036bf301db855cc036dc1fc1c1d8a3df547 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 07:44:10 -0700 Subject: [PATCH 04/27] Presets: the damaged-set test also covers Export As presets a_damaged_preset_set_keeps_the_other_sets (#5602) checks that one damaged set in the preferences file does not discard the others. It left out export_presets, which #5344 added and #6906 put back into load_presets_json, and recent_documents. The test now saves an Export As preset and checks that it survives each damaged set, recent_documents included. It also damages export_presets and documents on their own, and gives a null set beside the Export As presets. This is brandongunderson's change to the test in #6479, unchanged. Co-authored-by: brandongunderson <22452783+brandongunderson@users.noreply.github.com> --- crates/engine/src/presets/tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b7c0a3ede2..7a29a03a9a 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -758,19 +758,36 @@ fn a_damaged_preset_set_keeps_the_other_sets() { let mut s = Session::new(); let settings = json!({"width": 1600, "height": 900, "resolution": 144}); s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); let saved = s.presets.to_json(&s); - for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] { + for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "recent_documents", "custom_shapes", "layer_defaults", "swatches"] { let mut blob = saved.clone(); blob[key] = json!("not a preset set"); let mut next = Session::new(); next.load_presets_json(blob); assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`"); + assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`"); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); } + let mut blob = saved.clone(); + blob["export_presets"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.export_presets.is_empty()); + assert_eq!(next.presets.documents, s.presets.documents); + let mut blob = saved.clone(); + blob["documents"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.documents.is_empty()); + assert_eq!(next.presets.export_presets, s.presets.export_presets); // Null sets keep their defaults, as before. let mut next = Session::new(); - next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()})); + next.load_presets_json( + json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone(), "export_presets": saved["export_presets"].clone()}), + ); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len()); assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); assert_eq!(next.presets.documents, s.presets.documents); + assert_eq!(next.presets.export_presets, s.presets.export_presets); } From 2cca4faba0ae52e1eb31fc296f62be28dccb62c8 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 19 Oct 2026 10:22:57 +0800 Subject: [PATCH 05/27] Play .atn steps that switch to another open document Photoshop records a switch to another open document as a `select` of `Dcmn` by offset (-1 previous, 1 next). Import it as `document.activate {"offset": n}`, the tab that many places from the active one, and save it back as the same reference. Past the first or last tab is refused rather than wrapped (#7103). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 13 +++++- crates/engine/src/atn_cmds/tests.rs | 69 +++++++++++++++++++++++++++++ crates/engine/src/commands.rs | 29 ++++++++++-- crates/engine/src/tests.rs | 47 ++++++++++++++++++++ docs/gaps.md | 8 +++- 5 files changed, 160 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..195cef1fa7 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -13,7 +13,9 @@ //! work; tool selections and other steps with no document effect are imported switched off. //! //! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which -//! `commands::layer_param` resolves in whatever document the action plays on (#219). +//! `commands::layer_param` resolves in whatever document the action plays on (#219). A switch +//! to another open document (`select` of `Dcmn` by offset) becomes `document.activate +//! {"offset"}`, the tab that many places from the active one (#7103). use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; @@ -1442,6 +1444,9 @@ fn select_from(d: &Descriptor) -> Mapped { } } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + // Switching to another open document: its offset from the active one, -1 for the previous + // tab and 1 for the next (a template action copies from one and pastes into the other, #7103). + (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), _ => Mapped::Unsupported { harmless: false }, } } @@ -1476,6 +1481,11 @@ fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), + // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. + "document.activate" if p.get("document").is_none_or(Value::is_null) => { + let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) + } _ => return None, }) } @@ -2854,6 +2864,7 @@ const EVENTS: &[Event] = &[ "channel.target", "edit.undo", "edit.redo", + "document.activate", ], from: select_from, to: select_to, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..c50936133f 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -301,6 +301,7 @@ fn every_mapped_command_exports_and_imports_back() { ("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})), + ("document.activate", json!({"offset": -1})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), ("filter.pixelate.crystallize", json!({"cellSize": 5})), @@ -865,6 +866,74 @@ fn fsel(t: D) -> Descriptor { desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t) } +/// #7103: Photoshop records switching to another open document as a `select` of `Dcmn` by +/// offset (previous -1, next +1). The template action of the report, which copies from the +/// source image and pastes into the template opened after it, imports with no Photoshop steps, +/// plays across the two documents and saves back the same shape. With no previous document +/// open, playback stops at the switch instead of pasting into the wrong document. +#[test] +fn select_document_by_offset_imports_plays_and_saves_back() { + let doc = |offset: i32| desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])); + // A recorded document id (`DocI`) means nothing in another session: ignored, not written back. + let copy_in = vec![ + item("slct", "Select", Some(doc(-1).with("DocI", long(35.0)))), + item("setd", "Set", Some(fsel(enm("Ordn", "Al ")))), + item("copy", "Copy", None), + item("slct", "Select", Some(doc(1))), + item("past", "Paste", None), + ]; + let data = set_of("Template", vec![("Copy In", copy_in)]); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "source"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "template", "background": "transparent"})).unwrap(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let steps = &s.actions.list[0].steps; + assert_eq!(steps[0], ("document.activate".to_string(), json!({"offset": -1}))); + assert_eq!(steps[3], ("document.activate".to_string(), json!({"offset": 1}))); + + let pixel = |s: &mut Session| -> Vec { serde_json::from_value(s.execute("document.pixel", json!({"x": 4, "y": 4})).unwrap()).unwrap() }; + assert_eq!(pixel(&mut s), [0.0; 4]); + let r = s.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().doc.name, "template"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "the source's pixels are pasted on a new layer"); + assert_eq!(pixel(&mut s), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(s.documents()[0].doc.layers.len(), 1, "the source is only copied from"); + + let first = s.actions.list.clone(); + let r = s.execute("actions.save", json!({"set": "Template"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + let offsets: Vec = file.actions[0] + .items + .iter() + .filter_map(|i| match get_ref(i.descriptor.as_ref()?, "null")? { + [ReferenceItem::Offset { class, offset }] if trimmed(&class.class_id) == "Dcmn" => Some(*offset), + _ => None, + }) + .collect(); + assert_eq!(offsets, [-1, 1]); + assert!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("DocI").is_none()); + let mut t = Session::new(); + t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(t.actions.list, first); + // An absolute tab index has no Photoshop equivalent: it stays out of a strict save. + assert!(export_item("document.activate", &json!({"document": 0}), "S").is_none()); + assert!(export_item("document.activate", &json!({"document": 0, "offset": 1}), "S").is_none()); + + let mut one = Session::new(); + one.execute("file.new", json!({"width": 8, "height": 8, "name": "template"})).unwrap(); + one.execute("actions.load", json!({"data": data})).unwrap(); + let r = one.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert_eq!(r["failed"]["step"], 0, "{r}"); + assert_eq!(r["failed"]["error"], "no document 1 before the active one, which is 1 of 1", "{r}"); + assert_eq!(one.active().unwrap().doc.layers.len(), 1); +} + fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { let file = AtnFile { name: UnicodeString::new_nul(name), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..9cb069ffbe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1248,12 +1248,33 @@ fn build() -> Vec { label: "Activate Document", menu: &[], shortcut: None, - params: r##"{"document":index}"##, + params: r##"{"document":index} or {"offset":int} (the open document that many tabs after the active one, before it when negative: a Photoshop action's switch to another document) → {document}"##, enabled: has_doc, run: |s, p| { - let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; - let idx = usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))?; - if s.set_active(idx) { Ok(Value::Null) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } + // A null key counts as omitted. + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let idx = match (given("document"), given("offset")) { + (Some(_), Some(_)) => return Err(bad("document.activate", "give `document` or `offset`, not both")), + (None, None) => return Err(bad("document.activate", "needs `document` (a tab index) or `offset`")), + // Past the first or last tab is refused rather than wrapped, so an action that + // copies from one document never silently pastes into the wrong one (#7103). + (None, Some(o)) => { + let o = o.as_i64().ok_or_else(|| bad("document.activate", "`offset` must be an integer"))?; + let (active, len) = (s.active_index().ok_or(EngineError::NoDocument)?, s.documents().len()); + let target = i64::try_from(active).ok().and_then(|a| a.checked_add(o)).and_then(|i| usize::try_from(i).ok()); + let Some(i) = target.filter(|&i| i < len) else { + let side = if o < 0 { "before" } else { "after" }; + let msg = format!("no document {} {side} the active one, which is {} of {len}", o.unsigned_abs(), active + 1); + return Err(EngineError::Other(msg)); + }; + i + } + (Some(d), None) => { + let i = d.as_u64().ok_or_else(|| bad("document.activate", "`document` must be a tab index"))?; + usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))? + } + }; + if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } }, journal: false, }, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0849cd941a..2acd70fcc6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1470,6 +1470,53 @@ fn document_inspect_and_activate_invalid_index() { assert_eq!(err_act.to_string(), "no document at index 9"); } +/// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks the open +/// documents in tab order from the active one. Past either end it is refused, not wrapped onto +/// another document, and bad params leave the active document as it was. +#[test] +fn document_activate_by_offset_walks_the_tabs() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width": 2, "height": 2, "name": name})).unwrap(); + } + let active = |s: &Session| s.active().unwrap().doc.name.clone(); + assert_eq!(active(&s), "c"); + assert_eq!(s.execute("document.activate", json!({"offset": -2})).unwrap(), json!({"document": 0})); + assert_eq!(active(&s), "a"); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 1})); + assert_eq!(active(&s), "b"); + s.execute("document.activate", json!({"offset": 0})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"document": 2})).unwrap(), json!({"document": 2})); + // A null key counts as omitted. + s.execute("document.activate", json!({"document": null, "offset": -1})).unwrap(); + assert_eq!(active(&s), "b"); + + let err = s.execute("document.activate", json!({"offset": 2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 after the active one, which is 2 of 3"); + let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 before the active one, which is 2 of 3"); + for bad in [ + json!({}), + json!({"offset": null}), + json!({"offset": 1.5}), + json!({"offset": "1"}), + json!({"offset": i64::MAX}), + json!({"offset": i64::MIN}), + json!({"offset": u64::MAX}), + json!({"document": 0, "offset": 1}), + json!({"document": -1}), + ] { + assert!(s.execute("document.activate", bad.clone()).is_err(), "{bad}"); + assert_eq!(active(&s), "b", "{bad}"); + } + // Tabs, not the order the documents were opened in: with "c" dragged to the front, two tabs + // before "b" is "c". + s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + s.execute("document.activate", json!({"offset": -2})).unwrap(); + assert_eq!(active(&s), "c"); +} + /// #2166, measured in Photoshop 25.1: a layer mask and the layer pixels keep separate /// foreground/background pairs, swapped when the edit target changes. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..81828282fd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G11: `.atn` steps that switch to another open document import, play and save back, #7103; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -98,6 +98,11 @@ what an agent can't finish alone. mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-19:** `.atn` steps that switch to another open document (a `select` of `Dcmn` by + offset: the previous, next or Nth tab from the active one) import as `document.activate {"offset"}`, + play and save back in the same shape, so actions that copy from one open document and paste into + another no longer stop at the switch (#7103). An offset past the first or last open document is + refused, not wrapped. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, @@ -270,6 +275,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | G11: `.atn` steps that select another open document by offset import as `document.activate {"offset"}`, play and save back (#7103); estimate unchanged | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | From 249a3baabe447f1e79de6c74bc8d7a9eb34fc204 Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:26:45 -0600 Subject: [PATCH 06/27] ui: tolerate finger jitter on New Document double-tap egui's 6 px click distance rejects typical touchscreen double-taps; use a looser touch slop in second_click while keeping mouse tight. Co-authored-by: Cursor --- crates/ui-egui/src/new_doc_ui.rs | 6 +- crates/ui-egui/src/widgets.rs | 124 ++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..07727abdb9 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -424,7 +424,8 @@ fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map< let response = ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name); if response.clicked() { apply_saved_preset(f, preset); - create |= response.double_clicked() || response.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &response); } if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() { match app.run("document.presets.delete", json!({"name":preset.name})) { @@ -594,7 +595,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map apply_preset(f, p, &units), Card::Recent(settings) => apply_settings(f, settings), } - create |= resp.double_clicked() || resp.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &resp); } } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b393c0787d..2d84a8461b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -975,26 +975,37 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch /// Windows' default double-click time (Photoshop's; egui's own default is 0.3 s). const DOUBLE_CLICK_S: f64 = 0.5; -/// Whether this click on `resp` completes a double-click, counted the Windows way (Photoshop): -/// a click soon after a lone click at the same spot; the click after a double-click starts over. -/// (egui would call a third quick click a triple-click even when the first two weren't a double.) -fn second_click(ui: &Ui, resp: &Response) -> bool { +/// Finger jitter between taps easily exceeds egui's 6 px `max_click_dist`; ~32 px is about a third of an inch at 96 DPI and matches common touch hit-slop (Windows' mouse SM_CXDOUBLECLK is only 4 px and is not used for touch). +const TOUCH_MAX_CLICK_DIST: f32 = 32.0; + +/// True when this frame's pointer work came from a touch contact (still down, or a Touch event including End on the click frame). +fn pointer_from_touch(ui: &Ui) -> bool { + ui.input(|i| i.any_touches() || i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. }))) +} + +/// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. +/// +/// Time window is [`DOUBLE_CLICK_S`] (0.5 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; } let key = resp.id.with("pc-last-click"); let (time, pos) = ui.input(|i| (i.time, i.pointer.interact_pos())); - let dist = ui.ctx().options(|o| o.input_options.max_click_dist); - let last: Option<(f64, Pos2)> = ui.data(|d| d.get_temp(key)); + let touch = pointer_from_touch(ui); + let mouse_dist = ui.ctx().options(|o| o.input_options.max_click_dist); + let last: Option<(f64, Pos2, bool)> = ui.data(|d| d.get_temp(key)); + // Looser distance if either tap in the pair was touch (End clears `any_touches` but leaves Touch in `events`). + let dist = if touch || last.is_some_and(|(_, _, t)| t) { TOUCH_MAX_CLICK_DIST.max(mouse_dist) } else { mouse_dist }; let double = match (last, pos) { - (Some((t, p)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, + (Some((t, p, _)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, _ => false, }; ui.data_mut(|d| { if let (false, Some(q)) = (double, pos) { - d.insert_temp(key, (time, q)); + d.insert_temp(key, (time, q, touch)); } else { - d.remove::<(f64, Pos2)>(key); + d.remove::<(f64, Pos2, bool)>(key); } }); double @@ -1902,6 +1913,101 @@ mod tests { out.textures_delta.clear(); } } + + /// One primary press+release at `pos`. When `touch`, emit Touch Start/End with the pointer events (as egui-winit does) so [`super::second_click`] uses the touch distance. + fn pointer_click(ctx: &egui::Context, time: f64, pos: egui::Pos2, touch: bool) -> bool { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let button = |pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + let touch_ev = |phase| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase, + pos, + force: None, + }; + let mut saw = false; + for (t, mut events) in [ + (time, { + let mut v = vec![egui::Event::PointerMoved(pos), button(true)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::Start)); + } + v + }), + (time + 0.02, { + let mut v = vec![button(false)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::End)); + } + v + }), + ] { + let mut raw = egui::RawInput { + time: Some(t), + screen_rect: Some(area), + ..Default::default() + }; + raw.events.append(&mut events); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(area, egui::Sense::click()); + saw |= super::second_click(ui, &r); + }); + out.textures_delta.clear(); + } + saw + } + + /// #2274 touch: two taps ~20 px apart (past egui's 6 px mouse limit, inside our touch slop) within 0.5 s count as a double; the same mouse path does not. + #[test] + fn second_click_tolerates_touch_jitter_but_keeps_mouse_tight() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let a = egui::pos2(50.0, 50.0); + let b = egui::pos2(70.0, 50.0); // 20 px — past max_click_dist (6), inside TOUCH_MAX_CLICK_DIST (32) + for touch in [false, true] { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(0.0), + screen_rect: Some(area), + ..Default::default() + }, + |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }, + ); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, a, touch), "first click only arms the pair"); + let second = pointer_click(&ctx, 0.25, b, touch); + assert_eq!(second, touch, "touch={touch}: 20 px jitter is a double only for touch"); + } + } + + /// A mouse double past the time window is not a double, even at the same pixel. + #[test] + fn second_click_respects_the_half_second_window() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let p = egui::pos2(40.0, 40.0); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(0.0), + screen_rect: Some(area), + ..Default::default() + }, + |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }, + ); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, p, false)); + assert!(!pointer_click(&ctx, 0.1 + super::DOUBLE_CLICK_S + 0.05, p, false), "past Windows' 0.5 s window"); + } + use egui_kittest::{Harness, kittest::Queryable}; /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays From dc3a5c3b59e9380264f2a87a22073b6eaec1351d Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:43:13 -0600 Subject: [PATCH 07/27] test(ui): cover New Document touch double-tap with finger jitter Harness taps share Touch+pointer events on one frame so second_click sees touch; mouse path still rejects a 20 px gap. Co-authored-by: Cursor --- crates/ui-egui/src/new_doc_ui.rs | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 07727abdb9..c7c08754e0 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1210,6 +1210,28 @@ mod tests { h.run_steps(2); } + /// A finger tap at `at`. Events for one phase share a frame (`input_mut`), because `Harness::step` would otherwise run one frame per queued event and drop Touch before the click release (so `second_click` would not see touch). + fn touch_tap_at(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { + let touch = |phase| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase, + pos: at, + force: None, + }; + let button = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + h.input_mut().events.extend([egui::Event::PointerMoved(at), touch(egui::TouchPhase::Start), button(true)]); + h.step(); + h.input_mut().events.extend([touch(egui::TouchPhase::End), button(false)]); + h.step(); + h.run_steps(1); + } + /// The Width (0), Height (1) and Resolution (2) fields. fn field(h: &Harness<'static, PhotocraftApp>, i: usize) -> egui::Rect { h.query_all_by_role(Role::SpinButton).nth(i).map(|n| n.rect()).expect("a size field") @@ -1299,6 +1321,33 @@ mod tests { assert_pixel(&mut h, [0.0; 4]); } + /// Framework-style touch: the second tap lands ~20 px off (past egui's 6 px) and still creates. + #[test] + fn touch_double_tap_with_jitter_creates_from_builtin_preset() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + touch_tap_at(&mut h, at); + assert_eq!(fields(&h)["__preset"], "Portrait, 4 x 6"); + assert!(h.state().session.documents().is_empty(), "first tap only selects"); + touch_tap_at(&mut h, egui::pos2(at.x + 20.0, at.y)); + assert_eq!(created(&h), (1200, 1800, 300.0)); + } + + /// Mouse keeps egui's tight distance: 20 px between clicks is two singles, not a create. + #[test] + fn mouse_double_click_with_large_jitter_does_not_create() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + click_at(&mut h, at); + click_at(&mut h, at + egui::vec2(20.0, 0.0)); + assert!(h.state().session.documents().is_empty()); + assert_eq!(h.state().ui.dialogs.len(), 1, "dialog stays open"); + } + #[test] fn double_click_clipboard_preset_creates_its_size_and_current_details() { let mut h = harness(); From 155700deae81d992ad18e6ba5fe28286681e0fcd Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:43:25 -0600 Subject: [PATCH 08/27] ui: read double-click pos from the response hit Prefer interact_pointer_pos on the response so release-only frames still arm second_click. Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2d84a8461b..d05a6f492f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -985,13 +985,15 @@ fn pointer_from_touch(ui: &Ui) -> bool { /// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. /// -/// Time window is [`DOUBLE_CLICK_S`] (0.5 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +/// Time window is 0.5 s (Windows / Photoshop; egui's default is 0.3 s). Mouse uses egui's `max_click_dist` (6 px); touch uses 32 px so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; } let key = resp.id.with("pc-last-click"); - let (time, pos) = ui.input(|i| (i.time, i.pointer.interact_pos())); + // Prefer the response's hit pos: raw `pointer.interact_pos` can be unset on a release-only frame. + let pos = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())); + let time = ui.input(|i| i.time); let touch = pointer_from_touch(ui); let mouse_dist = ui.ctx().options(|o| o.input_options.max_click_dist); let last: Option<(f64, Pos2, bool)> = ui.data(|d| d.get_temp(key)); From 443e6a3fc8a1c7015b6337a52ba18177c07d4100 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 20:40:13 +0530 Subject: [PATCH 09/27] Refuse strict .atn export of an in-place layer duplicate Photoshop's Duplicate Layer has no in-place flag, so `layer.duplicate {"inPlace": true}` was exported as a plain Duplicate and replayed with the artboard copy beside the original. Strict export now fails on it and skipUnsupported reports it, as for a Close with a document target. Fixes #7200 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 5 +++++ crates/engine/src/atn_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..266de7faa8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1956,6 +1956,11 @@ fn duplicate_to(id: &str, p: &Value) -> Option { if id == "layer.duplicate" && jstr(p, "document") == Some("new") { return None; } + // Photoshop's Duplicate has no in-place flag: a plain one would place the artboard copy beside + // the original on playback. + if id == "layer.duplicate" && jbool(p, "inPlace") == Some(true) { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..60fe381dab 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1089,3 +1089,21 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); } } + +/// Photoshop's Duplicate Layer has no in-place flag: an in-place duplicate (the artboard copy +/// that stays on the original) must not be exported as a plain duplicate, which places the +/// copy beside the original on playback. +#[test] +fn duplicate_in_place_is_not_exported_as_plain_duplicate() { + let in_place = Action { name: "Probe".into(), steps: vec![("layer.duplicate".into(), json!({"inPlace": true}))], ..Default::default() }; + assert!(export("Audit5", &[&in_place], &[], false).is_err()); + let (_, skipped) = export("Audit5", &[&in_place], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.duplicate".into() }]); + for params in [json!({}), json!({"inPlace": false})] { + let plain = Action { name: "Plain".into(), steps: vec![("layer.duplicate".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + } +} From 24010398871440e3e4cdbf291d7c9daf33864b97 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 20:40:13 +0530 Subject: [PATCH 10/27] contributors: credit ksaxena149 by their GitHub profile name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 102c69e66c..a61b8ff6bc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -172,3 +172,6 @@ sync_github_name = false [people.Byxis] sync_github_name = true + +[people.ksaxena149] +sync_github_name = true From aa5497958322dd70194de092823f7c017c2a4e41 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 21:07:06 +0530 Subject: [PATCH 11/27] Record a duplicate's inPlace only when a copy is an artboard Alt-drag, Cmd+Opt-drag and Free Transform on a copy duplicate with `inPlace: true` for every layer, but the flag only changes anything for an artboard. With in-place duplicates now refused on strict .atn export, a recorded copy of a plain layer could no longer be exported, although a plain Duplicate replays it exactly. Recording now drops `inPlace` when no copy is an artboard, as `layer.select` already rewrites its recorded params. Refs #7200 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 37 ++++++++++++++++++++++++++++++ crates/engine/src/artboard_cmds.rs | 14 +++++++++++ crates/engine/src/jobs.rs | 7 ++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..59b52fb404 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -1025,6 +1025,43 @@ mod tests { } } + /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain + /// layer's recorded copy must still export to .atn, while an artboard's must be refused rather + /// than exported as a copy beside the original. + #[test] + fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Layer copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("actions.record", json!({"name": "Board copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[0]], &[], false).is_ok()); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).is_err()); + // Several selected layers are copied together: one artboard among them keeps `inPlace`. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let photo = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Layers copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both layers were copied"); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Mixed copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + } + #[test] fn named_selection_preserves_modes_and_duplicate_occurrences() { let mut s = Session::new(); diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 4bc4a829b2..cf17acb99f 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -183,6 +183,20 @@ pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<()> { Ok(()) } +/// Record a Duplicate's `inPlace` only when a copy (the selection after it) is an artboard: for +/// any other layer it changes nothing, and Photoshop's Duplicate can't express it, so a recorded +/// Alt-drag copy of a plain layer must stay exportable to .atn (#7200). +pub(crate) fn recorded_duplicate(s: &Session, mut p: Value) -> Value { + let Some(st) = s.active() else { return p }; + if st.selected_layers().into_iter().any(|id| st.doc.layer(id).is_some_and(|l| l.artboard().is_some())) { + return p; + } + if let Some(fields) = p.as_object_mut() { + fields.remove("inPlace"); + } + p +} + /// Grow the canvas so every artboard fits. Photoshop's artboard documents grow in every /// direction: a board left of or above the origin shifts the whole document (boards, their /// contents, guides, paths…) right/down by its overhang, then the canvas grows to show it. An diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b30e56da3a..504fe3a015 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -799,8 +799,11 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = match id { + "layer.select" if self.actions.recording.is_some() => crate::layer_multi_cmds::recorded_selection(self, params), + "layer.duplicate" if self.actions.recording.is_some() => crate::artboard_cmds::recorded_duplicate(self, params), + _ => params, + }; self.journal.push((id.to_string(), params)); } } From 372a468309f04c0445ad9ce47da8d7e5acf0eaab Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 14:31:27 -0600 Subject: [PATCH 12/27] fix(ui): tweak touch-only settings to be just a little looser Raise touch double-tap slop to 64 px after 32 px still rejected casual Framework taps. Co-authored-by: Cursor --- crates/ui-egui/src/new_doc_ui.rs | 4 ++-- crates/ui-egui/src/widgets.rs | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index c7c08754e0..6d1dce2ec6 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1321,7 +1321,7 @@ mod tests { assert_pixel(&mut h, [0.0; 4]); } - /// Framework-style touch: the second tap lands ~20 px off (past egui's 6 px) and still creates. + /// Framework-style touch: the second tap lands ~48 px off (past egui's 6 px and the old 32 px touch slop) and still creates. #[test] fn touch_double_tap_with_jitter_creates_from_builtin_preset() { let mut h = harness(); @@ -1331,7 +1331,7 @@ mod tests { touch_tap_at(&mut h, at); assert_eq!(fields(&h)["__preset"], "Portrait, 4 x 6"); assert!(h.state().session.documents().is_empty(), "first tap only selects"); - touch_tap_at(&mut h, egui::pos2(at.x + 20.0, at.y)); + touch_tap_at(&mut h, egui::pos2(at.x + 48.0, at.y)); assert_eq!(created(&h), (1200, 1800, 300.0)); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d05a6f492f..0a4f20fc31 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -975,8 +975,8 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch /// Windows' default double-click time (Photoshop's; egui's own default is 0.3 s). const DOUBLE_CLICK_S: f64 = 0.5; -/// Finger jitter between taps easily exceeds egui's 6 px `max_click_dist`; ~32 px is about a third of an inch at 96 DPI and matches common touch hit-slop (Windows' mouse SM_CXDOUBLECLK is only 4 px and is not used for touch). -const TOUCH_MAX_CLICK_DIST: f32 = 32.0; +/// Finger jitter between taps easily exceeds egui's 6 px `max_click_dist`; 64 px is about two-thirds of an inch at 96 DPI (roughly a fingertip pad). 32 px still rejected casual Framework double-taps; Windows' mouse SM_CXDOUBLECLK is only 4 px and is not used for touch. +const TOUCH_MAX_CLICK_DIST: f32 = 64.0; /// True when this frame's pointer work came from a touch contact (still down, or a Touch event including End on the click frame). fn pointer_from_touch(ui: &Ui) -> bool { @@ -985,7 +985,7 @@ fn pointer_from_touch(ui: &Ui) -> bool { /// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. /// -/// Time window is 0.5 s (Windows / Photoshop; egui's default is 0.3 s). Mouse uses egui's `max_click_dist` (6 px); touch uses 32 px so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +/// Time window is 0.5 s (Windows / Photoshop; egui's default is 0.3 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; @@ -1964,12 +1964,13 @@ mod tests { saw } - /// #2274 touch: two taps ~20 px apart (past egui's 6 px mouse limit, inside our touch slop) within 0.5 s count as a double; the same mouse path does not. + /// #2274 touch: taps past egui's 6 px mouse limit (and past the old 32 px touch slop) within 0.5 s still count as a double; the same mouse path does not. #[test] fn second_click_tolerates_touch_jitter_but_keeps_mouse_tight() { let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); let a = egui::pos2(50.0, 50.0); - let b = egui::pos2(70.0, 50.0); // 20 px — past max_click_dist (6), inside TOUCH_MAX_CLICK_DIST (32) + let mild = egui::pos2(70.0, 50.0); // 20 px — past mouse max_click_dist (6) + let wide = egui::pos2(50.0 + 48.0, 50.0); // past the old 32 px slop, inside TOUCH_MAX_CLICK_DIST (64) for touch in [false, true] { let ctx = egui::Context::default(); let mut out = ctx.run_ui( @@ -1984,9 +1985,24 @@ mod tests { ); out.textures_delta.clear(); assert!(!pointer_click(&ctx, 0.1, a, touch), "first click only arms the pair"); - let second = pointer_click(&ctx, 0.25, b, touch); + let second = pointer_click(&ctx, 0.25, mild, touch); assert_eq!(second, touch, "touch={touch}: 20 px jitter is a double only for touch"); } + // Touch-only: casual Framework-style drift beyond the previous 32 px budget. + let ctx = egui::Context::default(); + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(0.0), + screen_rect: Some(area), + ..Default::default() + }, + |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }, + ); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, a, true)); + assert!(pointer_click(&ctx, 0.25, wide, true), "48 px touch jitter is still a double"); } /// A mouse double past the time window is not a double, even at the same pixel. From a9df7b787b1cee2fa9350a8539e85cbf82af0a0d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:22:32 +0200 Subject: [PATCH 13/27] Refuse .atn export of crops with output size or resolution Fixes #7191 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/atn_cmds.rs | 5 +++++ crates/engine/src/atn_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 9e8eb6cb7c..a05b2a86bf 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2483,6 +2483,11 @@ fn crop_from(d: &Descriptor) -> Mapped { } fn crop_to(_: &str, p: &Value) -> Option { + // The W x H x Resolution output options have no field here: refuse rather than export a plain + // rectangle crop that keeps the old size and resolution on replay. + if ["targetWidth", "targetHeight", "resolution"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) { + return None; + } let mut d = desc("null"); if let (Some(x), Some(y), Some(w), Some(h)) = (jnum(p, "x"), jnum(p, "y"), jnum(p, "width"), jnum(p, "height")) { d = d.with("To ", D::Descriptor(desc("Rctn").with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 26865d4060..ce3dc135e2 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1387,3 +1387,24 @@ fn function_keys_keep_shift_and_command() { } assert_eq!(super::function_key_name(7, false, false), "F7"); } + +/// A crop with W x H x Resolution output options must not export as a plain rectangle crop +/// that keeps the old size and resolution on replay: the mapper has no field for them. +#[test] +fn crop_with_output_size_or_resolution_is_not_exported_as_plain_crop() { + for params in [ + json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6, "resolution": 144, "resample": "nearest"}), + json!({"x": 1, "y": 1, "width": 4, "height": 3, "resolution": 144}), + json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6}), + ] { + let sized = Action { name: "Probe".into(), steps: vec![("image.crop".into(), params)], ..Default::default() }; + assert!(export("Audit", &[&sized], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&sized], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "image.crop".into() }]); + } + let plain = Action { name: "Plain".into(), steps: vec![("image.crop".into(), json!({"x": 1, "y": 1, "width": 4, "height": 3}))], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("image.crop".to_string(), json!({"x": 1, "y": 1, "width": 4, "height": 3, "deleteCroppedPixels": true}))]); +} From 0fb98f6d91118e69b8ded451ea047aea3800e562 Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Tue, 20 Oct 2026 19:44:49 +0700 Subject: [PATCH 14/27] fix(prefs): say Rendering Mode applies at next launch next to it (#7939) Preferences > Performance > Rendering Mode only takes effect when PhotoCraft restarts, but the only "Applies at next launch." note sat at the bottom of the tab in the Graphics block, so switching CPU/GPU looked like it did nothing. The note now sits under the setting, as #1677 does for System Title Bar, and goes with it when a search hides the row. --- crates/ui-egui/src/prefs_ui.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d808ae32f4..b6ad03d032 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1384,6 +1384,9 @@ fn rendering_mode_row(ui: &mut egui::Ui, obj: &mut Map) { } }); ui.label(tl!("Automatic uses GPU acceleration when available and falls back to CPU rendering on errors.")); + // Next to the setting, as for System Title Bar (#1677): the renderer is picked when the + // window opens, so a change otherwise looks like it did nothing (#7939). + ui.label(RichText::new(tl!("Applies at next launch.")).color(Tokens::get(ui.ctx()).text_dim)); ui.add_space(8.0); } @@ -3830,6 +3833,28 @@ mod tests { assert!(h.state().custom_titlebar); } + /// #7939: switching Rendering Mode waits for the next launch, and says so next to the setting. + #[test] + fn rendering_mode_explains_next_launch() { + use egui_kittest::{Harness, kittest::Queryable}; + let obj = json!({"renderingMode": "auto", "historyStates": 50}).as_object().unwrap().clone(); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "performance", obj, &[], crate::i18n::Lang::from_pref("en"), None, ""), + obj, + ); + h.run_steps(4); + h.get_by_label("Rendering Mode"); + h.get_by_label("Applies at next launch."); + // A search that leaves Rendering Mode out leaves its notice out too. + let obj = json!({"renderingMode": "auto", "historyStates": 50}).as_object().unwrap().clone(); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "performance", obj, &[], crate::i18n::Lang::from_pref("en"), None, "history"), + obj, + ); + h.run_steps(4); + assert!(h.query_by_label("Applies at next launch.").is_none()); + } + #[test] fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { use egui_kittest::{Harness, kittest::Queryable}; From 37a0afa16d96b6d87d06064a83f92424ef44938d Mon Sep 17 00:00:00 2001 From: VishnuByrraju Date: Wed, 21 Oct 2026 11:10:56 +0530 Subject: [PATCH 15/27] =?UTF-8?q?fix(ui):=20Browse=E2=80=A6=20buttons=20fo?= =?UTF-8?q?r=20dialog=20folder=20and=20plug-in=20path=20fields=20(#7942,?= =?UTF-8?q?=20#7531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command dialogs with a `dir` field (Artboards to Files, Layers to Files, Layer Comps to Files, Package…) get a Browse… button that opens the system folder picker, and Install Plug-in… gets one that picks the .wasm file. The dialog body flags the clicked field and dialogs::show asks the file dialog, so the filled value lands in the dialog fields. --- crates/ui-egui/src/browse_field_tests.rs | 65 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 34 +++++++++++++ crates/ui-egui/src/filter_dialog.rs | 4 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/view_cmds.rs | 12 ++++- 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/browse_field_tests.rs diff --git a/crates/ui-egui/src/browse_field_tests.rs b/crates/ui-egui/src/browse_field_tests.rs new file mode 100644 index 0000000000..2aa3ef44f5 --- /dev/null +++ b/crates/ui-egui/src/browse_field_tests.rs @@ -0,0 +1,65 @@ +//! #7942 / #7531: path fields of command dialogs get a Browse… button (Artboards to Files' +//! folder, Install Plug-in…'s .wasm file) that fills the field from a file dialog. +use crate::PhotocraftApp; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::{Value, json}; + +fn harness(answers: Vec>) -> (Harness<'static, PhotocraftApp>, std::rc::Rc>>) { + let (show, asked) = crate::file_dialog::fake(answers); + let h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }) + }); + (h, asked) +} + +fn field(h: &Harness<'_, PhotocraftApp>, key: &str) -> Option { + h.state().ui.dialogs.last().and_then(|d| d.fields.get(key).cloned()) +} + +#[test] +fn artboards_to_files_folder_is_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo("/tmp/exports".into()))]); + h.state_mut().run("file.new", json!({"width": 64, "height": 64})).unwrap(); + h.state_mut().sync_views(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.export.artboardsToFiles", json!({})).unwrap(); + h.run_steps(4); + assert!(field(&h, "dir").is_some(), "the dialog has a Dir field"); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Folder { .. }])); + assert_eq!(field(&h, "dir"), Some(json!("/tmp/exports"))); + assert!(field(&h, crate::dialogs::BROWSE_FIELD).is_none(), "the request is consumed"); +} + +#[test] +fn cancelling_browse_keeps_the_typed_folder() { + let (mut h, _) = harness(vec![None]); + h.state_mut().run("file.new", json!({"width": 64, "height": 64})).unwrap(); + h.state_mut().sync_views(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.export.layersToFiles", json!({})).unwrap(); + h.run_steps(4); + let before = field(&h, "dir"); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert_eq!(field(&h, "dir"), before); +} + +#[test] +fn install_plugin_path_is_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::Paths(vec!["/tmp/blur.wasm".into()]))]); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "plugin.install", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Browse…").click(); + h.run_steps(4); + match asked.borrow().as_slice() { + [FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }] => assert_eq!(e, &["wasm".to_string()]), + other => panic!("unexpected requests {other:?}"), + } + assert_eq!(field(&h, "path"), Some(json!("/tmp/blur.wasm"))); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e27411b7e0..839527aa6d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,37 @@ use crate::PhotocraftApp; use crate::state::{Dialog, DialogKind}; use crate::widgets::{ButtonRole, DialogButton, dialog_buttons}; +/// A dialog body sets this field to the key of the text field whose Browse… button was clicked; +/// [`show`] takes it out again and opens the matching file or folder dialog. +pub(crate) const BROWSE_FIELD: &str = "__browse"; + +/// Browse… beside a path field of dialog `id`: a folder picker for `dir` fields, a `.wasm` picker +/// for Install Plug-in…'s `path`. The answer fills the field. +fn browse_field(app: &mut PhotocraftApp, id: u64, fields: &serde_json::Map, key: &str) { + let key = key.to_string(); + let r = if key == "dir" { + app.ask_folder(move |app, path| fill_field(app, id, &key, path)) + } else if fields.get("__command").and_then(Value::as_str) == Some("plugin.install") { + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + let request = crate::file_dialog::FileDialogRequest::Open { multiple: false, initial_dir, extensions: Some(vec!["wasm".into()]) }; + app.ask_file(request, move |app, answer| match answer { + crate::file_dialog::FileDialogAnswer::Paths(mut paths) if !paths.is_empty() => fill_field(app, id, &key, paths.remove(0)), + _ => Err("Install Plug-in reads a .wasm file from disk".into()), + }) + } else { + return; + }; + if let Err(e) = r.map(|_| ()).or_else(|e| if e == crate::file_dialog::CANCELLED { Ok(()) } else { Err(e) }) { + app.ui.status = e; + } +} + +fn fill_field(app: &mut PhotocraftApp, id: u64, key: &str, path: String) -> Result { + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + d.fields.insert(key.into(), json!(path)); + Ok(Value::Null) +} + /// Where this frame's dialogs are on screen (the canvas reads last frame's: it draws first). const RECTS: &str = "pc-dialog-rects"; @@ -382,6 +413,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } crate::field_tab::end(ui.ctx(), tab); + if let Some(Value::String(key)) = fields.remove(BROWSE_FIELD) { + browse_field(app, d.id, &fields, &key); + } if crate::guide_layout_ui::owns(&fields) || crate::brush_preset_dialog::owns(&fields) { // These dialogs own their right-side actions and validate Enter themselves. } else if crate::color_picker_ui::owns(&fields) { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index a908df5eba..c6fd5635cf 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -885,6 +885,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); ui.add(egui::TextEdit::singleline(&mut v).desired_width(200.0)); + // Install Plug-in…: pick the .wasm file instead of typing its path (#7531). + if cmd == "plugin.install" && p.key == "path" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { + f.insert(crate::dialogs::BROWSE_FIELD.into(), json!(p.key)); + } }); if v.is_empty() { f.remove(&p.key); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8bf2be90d7..6b9ba13dd4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3480,5 +3480,7 @@ mod channel_tint_tests { } } +#[cfg(test)] +mod browse_field_tests; #[cfg(test)] mod options_hit_area_tests; diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1cb51a6eab..7d56a22f53 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1081,6 +1081,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let labels = f.get("__labels").cloned().unwrap_or(Value::Null); let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + let mut browse: Option = None; egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); @@ -1127,7 +1128,13 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { crate::widgets::dropdown(ui, &format!("form-{k}"), &mut s, &refs, 200.0); } None => { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + // A folder field gets the system folder picker (#7942). + if k == "dir" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { + browse = Some(k.clone()); + } + }); } } json!(s) @@ -1148,6 +1155,9 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { ui.end_row(); } }); + if let Some(key) = browse { + f.insert(crate::dialogs::BROWSE_FIELD.into(), json!(key)); + } camera_data(ui, f); } From c0882504810e77a76030beb3a2b49a6e289331ba Mon Sep 17 00:00:00 2001 From: VishnuByrraju Date: Wed, 21 Oct 2026 12:15:13 +0530 Subject: [PATCH 16/27] fix(actions): .atn export of Image Size with resample none no longer adds a pixel resize (#7189) --- crates/engine/src/atn_cmds.rs | 14 +++++++++++--- crates/engine/src/atn_cmds/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 9e8eb6cb7c..210888e5c7 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2444,13 +2444,21 @@ fn shadows_highlights_to(_: &str, p: &Value) -> Option { fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); - if let Some(pc) = jnum(p, "percent") { + // `resample: "none"` changes the resolution only and the command ignores any size then. The + // descriptor has no "none" (it is the absence of `Intr`), so writing the ignored size would + // turn the step into a pixel resize on playback (#7189). + let resolution_only = jstr(p, "resample") == Some("none"); + if resolution_only && jnum(p, "resolution").is_none() { + // Nothing left to record: the step changes nothing. + return None; + } + if let Some(pc) = jnum(p, "percent").filter(|_| !resolution_only) { d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); } - if let Some(w) = jnum(p, "width") { + if let Some(w) = jnum(p, "width").filter(|_| !resolution_only) { d = d.with("Wdth", px(w)); } - if let Some(h) = jnum(p, "height") { + if let Some(h) = jnum(p, "height").filter(|_| !resolution_only) { d = d.with("Hght", px(h)); } if let Some(r) = jnum(p, "resolution") { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 26865d4060..385b7025ae 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1387,3 +1387,27 @@ fn function_keys_keep_shift_and_command() { } assert_eq!(super::function_key_name(7, false, false), "F7"); } + +/// `image.imageSize` with `resample: "none"` ignores width, height and percent; the `.atn` +/// round trip must not turn them into a pixel resize (#7189). +#[test] +fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { + let event = event_for(&export_item("image.imageSize", &json!({"resolution": 72}), "S").expect("exports").event).expect("a known event"); + let back = |p: Value| { + let item = export_item("image.imageSize", &p, "S")?; + let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") }; + let mut back = steps[0].1.clone(); + normalise(&mut back); + Some(back) + }; + for sized in [json!({"width": 12, "height": 10}), json!({"width": 12}), json!({"percent": 200}), json!({"width": 12, "percent": 50, "height": 3})] { + let mut p = sized.clone(); + p["resolution"] = json!(144); + p["resample"] = json!("none"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none"})), "{p}"); + } + // A step with nothing left to record is refused rather than exported as an empty (unsupported) event. + assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None); + // Real resizes are untouched. + assert_eq!(back(json!({"width": 12, "height": 10, "resample": "bicubic"})), Some(json!({"width": 12, "height": 10, "resample": "bicubic"}))); +} From 7eaca683a73f296dc96d726d4a51d960d0534346 Mon Sep 17 00:00:00 2001 From: Nikolai Nyegaard Date: Wed, 21 Oct 2026 00:37:12 +0200 Subject: [PATCH 17/27] Preferences: the theme swatch star no longer grows the row on hover - The favourite badge overhangs the swatch by 4 px; placing it with ui.put allocated that overhang, so the row grew and the swatches after the hovered one re-centred, flickering when the shift moved the swatch out from under the pointer - Click and hover through ui.interact, the star through icons::paint; neither allocates --- crates/ui-egui/src/prefs_ui.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d808ae32f4..6a63fa71ed 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1456,8 +1456,11 @@ fn theme_swatch(ui: &mut egui::Ui, kind: ThemeKind, id: &str, selected: &mut Str ui.painter().circle(center, BADGE, t.card, egui::Stroke::new(1.0, t.card_border)); let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }; - let star = egui::Button::image(crate::icons::image(icon, 10.0, tint)).frame(false); - let response = ui.put(badge, star).on_hover_text(action); + // Interact and paint without allocating: a widget placed at `badge` would grow the row by the + // 4 px the badge overhangs the swatch, re-centring the swatches after this one whenever the + // pointer arrives, and oscillating when that moves the swatch out from under it. + let response = ui.interact(badge, ui.id().with((id, "favorite")), Sense::click()).on_hover_text(action); + crate::icons::paint(ui, badge, icon, 10.0, tint); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action}: {label}"))); if response.clicked() { if favorite { From 171f6123370e9985639aaa7a4533cf172a55935c Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 21 Oct 2026 20:38:27 +0800 Subject: [PATCH 18/27] Keep Batch results when the action saves and then closes the document Batch dropped recorded Save / Save As / Save a Copy steps and saved the active document after the whole action, so "..., Save As, Close" failed on every file, and a Close that left another document active saved that one instead. Treat a recorded save as marking the result (Photoshop's "Override Action 'Save As' Commands"): write the document as the save found it when it is closed or another document is active at the end, else save it after the action as before (#3058). Droplets keep the save steps too. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/automate_cmds.rs | 8 ++- crates/engine/src/automate_cmds/tests.rs | 18 ++++++ crates/engine/src/file_cmds.rs | 69 ++++++++++++++++----- crates/engine/src/file_cmds/tests.rs | 77 ++++++++++++++++++++++++ docs/gaps.md | 10 ++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 4c78f90b34..a4afab33e8 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -314,9 +314,11 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?; let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?; let mut steps = parse_steps(steps_v, cmd)?; - // The droplet's output folder replaces recorded saves, and view steps don't apply, as in batch. - steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id)); - check_known(&steps, cmd)?; + // As in batch, the droplet's output folder replaces recorded saves, which are kept to mark the + // result, and view steps don't apply. + steps.retain(|(id, _)| crate::file_cmds::batch_keeps_step(id)); + let commands: Vec<_> = steps.iter().filter(|(id, _)| !crate::file_cmds::batch_save_point(id)).cloned().collect(); + check_known(&commands, cmd)?; // Play Action steps need their actions when the droplet runs on its own (#2794). Not under // `actions`: the CLI reads that key as an action set. let called = crate::actions_cmds::called_actions(&s.actions.list, &mut steps); diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 06d0e88f36..218ffe0431 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -166,6 +166,24 @@ fn droplets_are_written_and_run() { assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err()); } +/// A droplet keeps the action's recorded saves, which mark the result as in Batch, so one made from +/// an action ending in Save As and Close writes its files instead of failing on every one. +#[test] +fn droplets_keep_the_recorded_save_that_marks_the_result() { + let dir = tmp("droplet-save-close"); + let inputs = images(&dir, 1, 4, 2); + let mut s = Session::new(); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {}]]); + let p = json!({"path": format!("{dir}/Invert"), "steps": steps, "format": "png", "output": format!("{dir}/out"), "shim": false}); + let dp = s.execute("file.automate.createDroplet", p).unwrap()["path"].as_str().unwrap().to_string(); + let r = s.execute("file.automate.runDroplet", json!({"droplet": dp, "input": [dir.clone()]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); + let out = photocraft_io::import("img0.png", &std::fs::read(format!("{dir}/out/img0.png")).unwrap()).unwrap().document; + let px = photocraft_compose::render(&out, photocraft_geom::Rect::new(1, 1, 2, 2)).px[0]; + assert!((px[0] - 235.0 / 255.0).abs() < 1e-3, "img0's gray 20 inverted: {px:?}"); +} + #[cfg(unix)] #[test] fn droplet_shim_passes_shell_metacharacters_as_a_literal_path() { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 3cec3e4492..203acd668d 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1131,12 +1131,21 @@ pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result Result<()>) -> Value { let inputs: Vec = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); - process_tree(&inputs, output, format, save, suffix, f) + process_tree(&inputs, output, format, save, suffix, &|s, _| f(s).map(|()| false)) } /// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder -/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. -pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. `f` gets the +/// output path and returns `true` when it wrote the result there itself (Batch writes what a +/// recorded save marked); otherwise the active document is saved after it. +pub(crate) fn process_tree( + inputs: &[BatchInput], + output: &str, + format: &str, + save: SaveOpts, + suffix: &str, + f: &dyn Fn(&mut Session, &str) -> Result, +) -> Value { let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) @@ -1169,7 +1178,7 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/.hidden"), "png"); } -fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session, &str) -> Result) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); @@ -1180,9 +1189,10 @@ fn process_outputs<'a>(outputs: impl IntoIterator, sav let mut scratch = Session::new(); let doc = import(&file_name(path), &bytes)?; scratch.add_document(doc, Some(path.to_string())); - f(&mut scratch)?; - let d = scratch.active().ok_or(EngineError::NoDocument)?; - save_doc(&d.doc, &out, save)?; + if !f(&mut scratch, &out)? { + let d = scratch.active().ok_or(EngineError::NoDocument)?; + save_doc(&d.doc, &out, save)?; + } Ok(()) })(); match r { @@ -1217,15 +1227,26 @@ impl OutputClaims { } } +/// A recorded Save, Save As or Save a Copy, which marks the result Batch writes to its destination +/// (Photoshop's "Override Action 'Save As' Commands"). +pub(crate) fn batch_save_point(id: &str) -> bool { + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") +} + +/// Whether Batch and droplets keep a recorded step: save points mark the result, while other +/// shell-only saves and view steps (zoom, fit) don't change the output (#2752). +pub(crate) fn batch_keeps_step(id: &str) -> bool { + (batch_save_point(id) || !crate::actions_cmds::shell_save_command(id)) && !crate::actions_cmds::shell_view_command(id) +} + fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; let mut steps = parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; // Each result goes to the destination folder, which overrides the action's recorded Save / - // Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps - // (zoom, fit) don't change the output, so they are skipped (#2752). - steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id) && id != "file.export.quickExport"); - if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { + // Save As steps: they only mark the result. + steps.retain(|(id, _)| batch_keeps_step(id) && id != "file.export.quickExport"); + if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } let output = str_param(p, "output", cmd)?.to_string(); @@ -1248,16 +1269,32 @@ fn batch(s: &mut Session, p: &Value) -> Result { let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; - let run = |scratch: &mut Session| { + let run = |scratch: &mut Session, out: &str| { scratch.actions = actions.clone(); + // The document as the last recorded save found it, written once the action has run so a + // failing step still writes nothing. + let mut saved: Option> = None; for (id, params) in &steps { + if batch_save_point(id) { + saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + continue; + } let r = scratch.execute(id, params.clone())?; // A failed step inside a called action makes this file an error, not a saved result. if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { return Err(EngineError::Other(error)); } } - Ok(()) + // While the saved document is still open the steps after the save reach the result too + // (#3058), so it is saved after the action as usual. Closed after its save, or with another + // document left active, it is written as the save found it. + match saved { + Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { + save_doc(&doc, out, save)?; + Ok(true) + } + _ => Ok(false), + } }; let r = match names { Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), @@ -1276,14 +1313,14 @@ fn image_processor(s: &mut Session, p: &Value) -> Result { (w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})), }; let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false); - let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { + let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch, _| { if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) { scratch.execute("image.mode.rgb", json!({}))?; } if let Some(fp) = &fit { fit_image(scratch, fp)?; } - Ok(()) + Ok(false) }); Ok(r) } @@ -1775,7 +1812,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 9b5ec43a78..4b8de72219 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1428,3 +1428,80 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { assert_eq!(std::fs::read(&first).unwrap(), before[0]); assert_eq!(std::fs::read(&second).unwrap(), before[1]); } + +/// Batch takes the result at the action's recorded Save, Save As or Save a Copy, as Photoshop's +/// "Override Action 'Save As' Commands" does: a document closed after its save is written as the +/// save found it. The usual "…, Save As, Close" ending failed on every file: the Save was dropped, +/// and nothing was left open to save after the action. +#[test] +fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() { + let dir = tmp("batch-save-close"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let saved = |out: &str| photocraft_io::import("red.png", &std::fs::read(join(out, "red.png")).unwrap()).unwrap().document; + let mut s = Session::new(); + let out = join(&dir, "close"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"], json!([join(&out, "red.png")])); + assert_eq!(photocraft_compose::render(&saved(&out), Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red"); + + // Save a Copy marks the result the same way, and its recorded path is never written. + let out = join(&dir, "copy"); + let elsewhere = join(&dir, "elsewhere.png"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {"path": elsewhere}], ["file.close", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(photocraft_compose::render(&saved(&out), Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0]); + assert!(!std::path::Path::new(&elsewhere).exists()); + + // With no Save step the result is still saved after the action; with nothing left open then, + // the file is an error, not a silent skip. + let out = join(&dir, "closed"); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": [["file.close", {}]]})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}"); +} + +/// The document a recorded save marked is the result, not the one left active after it closed. +/// Here the action saves and closes a second document it made, then inverts the input; the output +/// used to be the inverted input, written in place of the result without an error. +#[test] +fn batch_saves_the_document_active_at_the_save_not_the_one_left_after_it() { + let dir = tmp("batch-save-second"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let out = join(&dir, "out"); + let steps = json!([ + ["file.new", {"width": 4, "height": 4, "background": "#00ff00"}], + ["file.save", {}], + ["file.close", {}], + ["image.adjustments.invert", {}] + ]); + let r = Session::new().execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let saved = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!((saved.size.width, saved.size.height), (4, 4)); + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 0.0, 1.0]); +} + +/// While the saved document is open, the steps after its save still reach the result (#3058: a +/// recorded Save then Rotate writes the rotated image). A step failing after the save writes +/// nothing, as when Batch saved only after the action. +#[test] +fn batch_applies_the_steps_after_a_save_while_its_document_is_open() { + let dir = tmp("batch-save-then-edit"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let mut s = Session::new(); + let out = join(&dir, "edited"); + let steps = json!([["file.saveAs", {}], ["image.adjustments.invert", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let saved = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0]); + + let out = join(&dir, "failed"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["layer.select", {"layer": "missing"}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}"); + assert!(!std::path::Path::new(&join(&out, "red.png")).exists(), "{r}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..d45f6f6c82 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -102,6 +102,13 @@ what an agent can't finish alone. mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-21:** Batch and droplets take each result at the action's recorded Save, Save As + or Save a Copy step, written to the destination folder (Photoshop's "Override Action 'Save As' + Commands"). A document closed after its save is written as the save found it, so actions ending + in Save As and Close no longer fail on every file, and a Close that leaves another document + active no longer saves that one instead. While the saved document stays open, the steps after + the save still apply (#3058). Saves inside a played action, and the CLI's `batch` loop, still + save after the action. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, @@ -274,6 +281,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G11: Batch and droplets take the result at the action's recorded Save / Save As step (Photoshop's "Override Action 'Save As' Commands"); estimate unchanged | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | From 25fa543b1960ba9606c0c1f5ab0b5b5a715f6703 Mon Sep 17 00:00:00 2001 From: nathanZW Date: Wed, 21 Oct 2026 16:14:54 +0200 Subject: [PATCH 19/27] fix(ui): drop the inner outline on resizable dialogs (#8199) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 51 ++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e27411b7e0..3ffe593a04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -130,7 +130,15 @@ fn show_modal( } if let Some(size) = resizable { let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); - egui::Resize::default().id(id.with("resize")).default_size(size.min(max)).min_size(egui::vec2(560.0, 400.0)).max_size(max).show(ui, contents) + // No stroke: Resize outlines its contents 2 points out, an inner box inside the + // dialog's frame that left the title and buttons flush against it (#8199). + egui::Resize::default() + .id(id.with("resize")) + .with_stroke(false) + .default_size(size.min(max)) + .min_size(egui::vec2(560.0, 400.0)) + .max_size(max) + .show(ui, contents) } else { contents(ui) } @@ -1201,4 +1209,45 @@ mod preference_resize_tests { h.run_steps(10); assert_eq!(frame(&h), smaller); } + + /// #8199: no outline is drawn just around the contents, which left the title flush against a + /// second, inner box instead of the frame's margin like the other dialogs. + #[test] + fn preferences_has_no_inner_outline_hugging_its_title() { + fn find<'a>(shape: &'a egui::Shape, out: &mut Vec<&'a egui::Shape>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| find(s, out)), + s => out.push(s), + } + } + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + crate::prefs_ui::open_preferences(&mut app, "general"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let mut shapes = Vec::new(); + h.output().shapes.iter().for_each(|s| find(&s.shape, &mut shapes)); + let title = shapes + .iter() + .find_map(|s| match s { + egui::Shape::Text(t) if t.galley.job.text == "Preferences" => Some(t.pos), + _ => None, + }) + .expect("the title is painted"); + let hugging: Vec = shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) if r.stroke.width > 0.0 && r.rect.contains(title) && (0.0..4.0).contains(&(title.x - r.rect.left())) => Some(r.rect), + _ => None, + }) + .collect(); + assert!(hugging.is_empty(), "an outline within 4 points of the title at {title:?}: {hugging:?}"); + } } From e3de56c6399de05d2914bf8c22d2dbcb574eeafb Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:26:44 +0300 Subject: [PATCH 20/27] fix(actions): Hue/Saturation uses Photoshop's hue key H in .atn files (#8302, #8308) The Hst2 adjustment wrote and read hue under 'Hue '; Photoshop's key is 'H ' (stringID hue, as PhotoCraft's PSD Hue/Saturation smart filter already uses). Exported actions failed in Photoshop with a program error, and Photoshop-recorded actions imported without their hue. Export writes 'H '; import reads it, and still reads 'Hue ' from PhotoCraft's own older files. --- crates/engine/src/atn_cmds.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 343a5920a0..d742187f11 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -565,6 +565,9 @@ fn curves_to(p: &Value) -> Option { Some(desc("Crvs").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) } +/// The hue key of a Hue/Saturation adjustment (`Hst2`): Photoshop's `H ` (#8302). +const HUE_KEY: &str = "H "; + const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; fn hue_sat_from(d: &Descriptor) -> Option { @@ -575,8 +578,11 @@ fn hue_sat_from(d: &Descriptor) -> Option { for e in get_list(d, "Adjs").unwrap_or(&[]) { let D::Descriptor(e) = e else { return None }; let mut m = Map::new(); - for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { - if let Some(v) = get_num(e, key) { + // Photoshop's hue key is `H ` (stringID "hue", #8302); PhotoCraft wrote `Hue ` before, + // so its own older files still read. + let hue = get_num(e, HUE_KEY).or_else(|| get_num(e, "Hue ")); + for (param, v) in [("hue", hue), ("saturation", get_num(e, "Strt")), ("lightness", get_num(e, "Lght"))] { + if let Some(v) = v { m.insert(param.into(), v.into()); } } @@ -606,7 +612,7 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { } } } - for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + for (key, param) in [(HUE_KEY, "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); } Some(e) From 0c1a59495f153a115129d5572bae7b60b32d4fee Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:46:33 +0300 Subject: [PATCH 21/27] test(actions): Hue/Saturation writes and reads hue under H in .atn (#8302, #8308) --- crates/engine/src/atn_cmds/tests.rs | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 638fc45cb5..a5d2229dc0 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1401,3 +1401,46 @@ fn function_keys_keep_shift_and_command() { } assert_eq!(super::function_key_name(7, false, false), "F7"); } + +/// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written +/// and read as `Hue `: exported actions failed in Photoshop, and Photoshop-recorded ones lost +/// their hue on import with no warning. PhotoCraft's own older files (`Hue `) still read. +#[test] +fn hue_saturation_uses_photoshops_hue_key() { + let params = json!({"colorize": false, "hue": 30, "saturation": 20, "lightness": -7, "greens": {"hue": -12, "saturation": 0, "lightness": 0, "range": [75, 105, 135, 165]}}); + let a = Action::new("H", vec![("image.adjustments.hueSaturation".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let d = file.actions[0].items[0].descriptor.clone().expect("a descriptor"); + let entries: Vec = + get_list(&d, "Adjs").expect("Adjs").iter().filter_map(|e| if let D::Descriptor(e) = e { Some(e.clone()) } else { None }).collect(); + assert_eq!(entries.len(), 2); + assert_eq!(get_num(&entries[0], "H "), Some(30.0), "the master hue under H"); + assert_eq!(get_num(&entries[1], "H "), Some(-12.0), "a range's hue under H"); + assert!(entries.iter().all(|e| get_num(e, "Hue ").is_none()), "no Hue key"); + + // A Photoshop-recorded step (H) and an older PhotoCraft one (Hue ) both import with the hue. + let step = |key: &str| { + desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Clrz", D::Boolean(false)) + .with("Adjs", D::List(vec![D::Descriptor(desc("Hst2").with(key, long(30.0)).with("Strt", long(20.0)).with("Lght", long(-7.0)))])) + }; + for key in ["H ", "Hue "] { + let e = event_for(&item("HStr", "", None).event).expect("HStr imports"); + match (e.from)(&step(key)) { + Mapped::Steps(steps) => { + assert_eq!(steps[0].0, "image.adjustments.hueSaturation"); + assert_eq!(steps[0].1["hue"], json!(30.0), "{key:?}: {}", steps[0].1); + assert_eq!(steps[0].1["saturation"], json!(20.0)); + } + Mapped::Unsupported { .. } => panic!("{key:?} is unsupported"), + } + } + // Through a whole file, saved and loaded back: the hue survives. + let back = import(&bytes).unwrap(); + let mut p = back.actions[0].steps[0].1.clone(); + normalise(&mut p); + assert_eq!(p["hue"], json!(30), "{p}"); +} From a31086333077cf228ef2cb5c864392ef9a696cf7 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:30:15 -0400 Subject: [PATCH 22/27] Actions: Photo Filter saves to .atn under the name Photoshop runs (#8301) Save Actions wrote Photo Filter steps with the four-char event code PhtF. Photoshop loads the file but stops on playback ("The command 'Photo Filter' is not currently available"). The same descriptor runs under the string event photoFilter (measured by the reporter in Photoshop 27.11 at 8 and 16 bits). PhtF is the adjustment's class, not an event. - Photo Filter now exports by name (photoFilter), like Black & White, Box Blur and Surface Blur since dfe94d6a. The descriptor is unchanged. - Import still accepts PhtF through LEGACY_CODES, so actions PhotoCraft saved before keep loading, and they re-save under the name. - Tests: Photo Filter joins the by-name/legacy-code test (export id, import by name and old code, near misses stay Photoshop steps). A new test records the issue's exact step, saves it strictly with nothing skipped, loads a Photoshop-authored step and an old PhtF step, re-saves both as photoFilter, and plays both the same as a direct run at 8 and 16 bits. Both tests fail without the fix. --- crates/engine/src/atn_cmds.rs | 9 ++-- crates/engine/src/atn_cmds/tests.rs | 76 +++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 343a5920a0..06f2cfa4ef 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -795,7 +795,8 @@ fn gradient_map_to(p: &Value) -> Option { ) } -/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and +/// An adjustment: its Photoshop class (usually also the direct event's code; Black & White and +/// Photo Filter run by name), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { class: &'static str, @@ -2772,7 +2773,8 @@ const EVENTS: &[Event] = &[ adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), - adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), + // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). + adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), Event { code: "", name: "blackAndWhite", @@ -3057,7 +3059,8 @@ const EVENTS: &[Event] = &[ /// these codes fails ("the command is not currently available"), and `Fltn` is the /// `flatness` property, not the flatten event. Import keeps accepting them so actions saved /// before still load; export never writes them. -const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; +const LEGACY_CODES: &[(&str, &str)] = + &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter")]; fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 638fc45cb5..76f7ca74a7 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1084,17 +1084,19 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } -/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and -/// `BlnW` ("the command is not currently available"), while the names `boxblur`, -/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn`, `BlnW` +/// and `PhtF` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite`, `photoFilter` and the code `FltI` run. #[test] fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let photo_filter = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); let cases = [ ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), ("layer.flattenImage", json!({}), "FltI", "Fltn"), ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ("image.adjustments.photoFilter", photo_filter, "photoFilter", "PhtF"), ]; for (id, params, written, legacy) in &cases { let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -1141,7 +1143,7 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, // never a panic or a wrong command. - for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx", "PHTF", "PhtF ", "photofilter"] { let e = event_for(&item(event, "", None).event); assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); } @@ -1155,6 +1157,72 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( } } +/// #8301: Photoshop stops on a Photo Filter step saved as `PhtF` ("not currently available") +/// and runs the same descriptor under the name `photoFilter`. A recorded step saves by name, +/// a Photoshop-authored step re-saves by name, and actions saved before with `PhtF` still load +/// and play the same. +#[test] +fn photo_filter_saves_under_the_name_photoshop_runs() { + let params = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080"})).unwrap(); + s.execute("actions.record", json!({"name": "Filter"})).unwrap(); + s.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved = s.execute("actions.save", json!({"action": "Filter"})).unwrap(); + assert_eq!(saved["skipped"], json!([]), "{saved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let step = &file.actions[0].items[0]; + assert_eq!(step.event, AtnEvent::Name("photoFilter".into()), "never the code PhtF"); + assert_eq!(step.dictionary, "Photo Filter"); + let d = step.descriptor.clone().unwrap(); + for key in ["Clr ", "Dnst", "PrsL"] { + assert!(d.get(key).is_some(), "the descriptor keeps {key}: {d:?}"); + } + + // A Photoshop-authored step (by name) and an old PhotoCraft one (`PhtF`) both load to the + // command, and saving them again writes the name. + let data = + set_of("S", vec![("Photoshop", vec![item("photoFilter", "Photo Filter", Some(d.clone()))]), ("Legacy", vec![item("PhtF", "Photo Filter", Some(d))])]); + let mut t = Session::new(); + let r = t.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + for a in &t.actions.list { + assert_eq!(a.steps.len(), 1, "{}", a.name); + assert_eq!(a.steps[0].0, "image.adjustments.photoFilter", "{}", a.name); + let mut p = a.steps[0].1.clone(); + normalise(&mut p); + assert_eq!(p, params, "{}", a.name); + } + let resaved = t.execute("actions.save", json!({"set": "S"})).unwrap(); + assert_eq!(resaved["skipped"], json!([]), "{resaved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(resaved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["photoFilter", "photoFilter"]); + + // Both play like the command run directly, at 8 and 16 bits. + for depth in [8, 16] { + let pixel = |s: &Session| { + let st = s.active().unwrap(); + let surface = st.doc.layers.first().unwrap().surface().unwrap(); + [0, 1, 2].map(|c| surface.sample_channel(3, 3, c)) + }; + let new_doc = |t: &mut Session| { + t.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080", "depth": depth})).unwrap(); + }; + new_doc(&mut t); + t.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + let direct = pixel(&t); + assert_ne!(direct, [128.0 / 255.0; 3], "the filter changes the grey"); + for action in ["Photoshop", "Legacy"] { + new_doc(&mut t); + let r = t.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{action}: {r}"); + assert_eq!(pixel(&t), direct, "{action} at {depth} bits"); + } + } +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test] From e481bbe32ab63a37dd8af4d99b8741ea4f99c3ab Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:30:21 -0400 Subject: [PATCH 23/27] New Document: labels show the arrow, not a text cursor (#8331) In File > New, the field labels and headings (Width, Height, Resolution, Color Mode, Background Contents, PRESET DETAILS, the Saved tab's text) were selectable egui labels, so hovering them showed the I-beam as if they were editable. In Photoshop they are static text. - The dialog body turns off selectable labels, so every static label keeps the arrow. The name field keeps its text cursor and the size fields their scrub cursor. - The preset-save error message stays selectable so it can be copied. - Test: a kittest hovers the six labels and the empty Saved list (arrow) and the name field (text cursor). It fails without the fix. --- crates/ui-egui/src/new_doc_ui.rs | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..7288419abe 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -488,7 +488,8 @@ fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> bool { let t = Tokens::get(ui.ctx()); + // Headings and field labels are static, as in Photoshop: no text cursor over them, no + // drag-selecting them (#8331). Text fields keep theirs. + ui.style_mut().interaction.selectable_labels = false; let cat = get_s(f, "__category", "Recent"); let mut create = false; // Category tabs. @@ -1273,6 +1277,37 @@ mod tests { } } + fn cursor_over(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) -> egui::CursorIcon { + h.hover_at(at); + h.run_steps(2); + h.output().platform_output.cursor_icon + } + + /// #8331: headings and field labels keep the arrow; only the text fields show the I-beam. + #[test] + fn labels_show_the_arrow_and_only_text_fields_the_text_cursor() { + use egui_kittest::kittest::NodeT; + let mut h = harness(); + let label_at = |h: &Harness<'static, PhotocraftApp>, text: &str| { + h.query_all_by_label(text) + .find(|n| n.accesskit_node().role() == Role::Label) + .map(|n| n.rect().center()) + .unwrap_or_else(|| panic!("a {text:?} label")) + }; + for text in ["PRESET DETAILS", "Width", "Height", "Resolution", "Color Mode", "Background Contents"] { + let at = label_at(&h, text); + assert_eq!(cursor_over(&mut h, at), egui::CursorIcon::Default, "over {text:?}"); + } + // The name field still shows the text cursor, so the check above can tell them apart. + let name = h.query_all_by_role(Role::TextInput).next().map(|n| n.rect().center()).expect("the name field"); + assert_eq!(cursor_over(&mut h, name), egui::CursorIcon::Text, "over the name field"); + // The Saved tab's labels too. + h.get_by_label("Saved").click(); + h.run_steps(2); + let at = label_at(&h, "No saved presets yet."); + assert_eq!(cursor_over(&mut h, at), egui::CursorIcon::Default, "over the empty Saved list"); + } + #[test] fn double_click_builtin_preset_creates_its_size_and_current_details() { let mut h = harness(); From d1c30954aacdac7831ebfb0e408a79ba9b2ac11b Mon Sep 17 00:00:00 2001 From: bommerts <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:08:17 -0400 Subject: [PATCH 24/27] Fix compound layer property exports for Photoshop actions --- crates/engine/src/atn_cmds.rs | 38 ++++- .../engine/src/atn_cmds/layer_props_tests.rs | 151 ++++++++++++++++++ docs/gaps.md | 7 +- docs/target-app-parity.md | 5 +- 4 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/atn_cmds/layer_props_tests.rs diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 343a5920a0..1b41a64e3b 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3201,6 +3201,29 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } +/// Photoshop applies only the rename when a Set mixes it with opacity or blend mode (#8304). +/// Write each layer property separately, renaming last so named references still resolve. +fn export_items(id: &str, params: &Value, set: &str) -> Option> { + let item = export_item(id, params, set)?; + if id != "layer.setProps" { + return Some(vec![item]); + } + let descriptor = item.descriptor.as_ref()?; + let properties = get_obj(descriptor, "T ")?; + if properties.items.len() <= 1 { + return Some(vec![item]); + } + let target = descriptor.get("null")?; + let mut items = Vec::with_capacity(properties.items.len()); + for (key, value) in properties.items.iter().filter(|(k, _)| !k.is("Nm ")).chain(properties.items.iter().filter(|(k, _)| k.is("Nm "))) { + let mut property = properties.clone(); + property.items = vec![(key.clone(), value.clone())]; + let descriptor = desc("null").with("null", target.clone()).with("T ", D::Descriptor(property)); + items.push(AtnItem { descriptor: Some(descriptor), ..item.clone() }); + } + Some(items) +} + /// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options /// `Shift+F5`, `Cmd+F5` or `Cmd+Shift+F5` (#7646). pub fn function_key_name(key: u16, shift: bool, command: bool) -> String { @@ -3241,17 +3264,19 @@ pub fn export( (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { - let Some(mut item) = export_item(id, params, set) else { + let Some(items) = export_items(id, params, set) else { if skip_unsupported { skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); continue; } return Err(format!("step {} of `{}` (`{id}`) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name)); }; - item.enabled = a.step_enabled(i); - item.with_dialog = a.dialogs.contains(&i); - item.dialog_options = if item.with_dialog { 2 } else { 0 }; - out.items.push(item); + for mut item in items { + item.enabled = a.step_enabled(i); + item.with_dialog = a.dialogs.contains(&i); + item.dialog_options = if item.with_dialog { 2 } else { 0 }; + out.items.push(item); + } } file.actions.push(out); } @@ -3470,3 +3495,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod layer_props_tests; diff --git a/crates/engine/src/atn_cmds/layer_props_tests.rs b/crates/engine/src/atn_cmds/layer_props_tests.rs new file mode 100644 index 0000000000..424af7bd55 --- /dev/null +++ b/crates/engine/src/atn_cmds/layer_props_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +/// PhotoCraft's own roundtrip accepts a combined Set, but Photoshop applies only its rename. +#[test] +fn compound_properties_export_as_separate_sets_with_rename_last() { + for target in [None, Some("Original")] { + let mut params = json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"}); + if let Some(name) = target { + params["layer"] = json!(name); + } + let action = Action::new("Properties", vec![("layer.setProps".into(), params.clone())]); + let (bytes, skipped) = export("Set", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let items = &file.actions[0].items; + assert_eq!(items.len(), 3); + let expected = [("Opct", pct(37.0)), ("Md ", enm("BlnM", "Mltp")), ("Nm ", text("Renamed"))]; + for (item, (key, value)) in items.iter().zip(expected) { + assert_eq!(item.event.id(), "setd"); + let descriptor = item.descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("null"), Some(&layer_ref(¶ms))); + let properties = get_obj(descriptor, "T ").unwrap(); + assert!(properties.class_id.is("Lyr ")); + assert_eq!(properties.items.len(), 1, "Photoshop must receive each property independently"); + assert_eq!(properties.get(key), Some(&value)); + } + } +} + +#[test] +fn expanded_properties_keep_flags_and_original_skip_indices() { + let mut action = Action::new( + "Flags", + vec![ + ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), + ("filter.render.clouds".into(), json!({})), + ], + ); + action.disabled.insert(0); + action.dialogs.insert(0); + let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); + assert_eq!(skipped, [Skipped { action: "Flags".into(), step: 2, id: "filter.render.clouds".into() }]); + let file = atn::parse(&bytes).unwrap(); + let items = &file.actions[0].items; + assert_eq!(items.len(), 4); + for item in &items[..3] { + assert!(!item.enabled); + assert!(item.with_dialog); + assert_eq!(item.dialog_options, 2); + } + assert_eq!(items[3].event.id(), "GsnB"); + assert!(items[3].enabled); + assert!(!items[3].with_dialog); + assert_eq!(items[3].dialog_options, 0); + let back = import(&bytes).unwrap(); + assert_eq!(back.actions[0].disabled.iter().copied().collect::>(), [0, 1, 2]); + assert_eq!(back.actions[0].dialogs.iter().copied().collect::>(), [0, 1, 2]); +} + +#[test] +fn invalid_compound_properties_fail_or_skip_the_whole_source_step() { + let action = Action::new( + "Atomic", + vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), + ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "invalid blend"})), + ("select.inverse".into(), json!({})), + ], + ); + let error = export("Set", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2"), "{error}"); + let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); + assert_eq!(skipped, [Skipped { action: "Atomic".into(), step: 1, id: "layer.setProps".into() }]); + let file = atn::parse(&bytes).unwrap(); + let events: Vec<_> = file.actions[0].items.iter().map(|item| item.event.id()).collect(); + assert_eq!(events, ["GsnB", "Invs"]); +} + +#[test] +fn single_layer_properties_keep_their_existing_descriptor() { + for params in [json!({"name": "Renamed"}), json!({"opacity": 0.37}), json!({"blend": "Multiply"}), json!({"fill": 0.5}), json!({"visible": false})] { + let action = Action::new("Single", vec![("layer.setProps".into(), params.clone())]); + let expected = export_item("layer.setProps", ¶ms, "Set").unwrap(); + let (bytes, skipped) = export("Set", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), 1); + assert_eq!(file.actions[0].items[0].event, expected.event); + assert_eq!(file.actions[0].items[0].descriptor, expected.descriptor); + } +} + +#[test] +fn fill_and_visibility_also_export_separately_before_rename() { + let action = Action::new("Other properties", vec![("layer.setProps".into(), json!({"name": "Renamed", "fill": 0.5, "visible": false}))]); + let (bytes, _) = export("Set", &[&action], &[], false).unwrap(); + let file = atn::parse(&bytes).unwrap(); + let keys: Vec<_> = file.actions[0] + .items + .iter() + .map(|item| { + let properties = get_obj(item.descriptor.as_ref().unwrap(), "T ").unwrap(); + assert_eq!(properties.items.len(), 1); + trimmed(&properties.items[0].0) + }) + .collect(); + assert_eq!(keys, ["fillOpacity", "Vsbl", "Nm"]); +} + +#[test] +fn expanded_properties_replay_like_direct_edits_at_every_depth() { + for depth in [8, 16, 32] { + for named in [false, true] { + let prepare = || { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 2, "height": 2, "depth": depth})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Original"})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Other"})).unwrap(); + if !named { + session.execute("layer.select", json!({"layer": "Original"})).unwrap(); + } + session + }; + let mut params = json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"}); + if named { + params["layer"] = json!("Original"); + } + let mut direct = prepare(); + direct.execute("layer.setProps", params.clone()).unwrap(); + let action = Action::new("Properties", vec![("layer.setProps".into(), params)]); + let (bytes, _) = export("Set", &[&action], &[], false).unwrap(); + let mut replay = prepare(); + replay.actions.list = import(&bytes).unwrap().actions; + let result = replay.execute("actions.play", json!({"action": "Properties"})).unwrap(); + assert!(result["failed"].is_null(), "{result}"); + assert_eq!(result["ran"], 3); + let state = |session: &Session| { + session + .active() + .unwrap() + .doc + .walk() + .iter() + .map(|(_, _, layer)| (layer.name.clone(), layer.opacity, layer.blend, layer.visible)) + .collect::>() + }; + assert_eq!(state(&direct), state(&replay), "depth={depth}, named={named}"); + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 3c56d6d35a..dcc88878c3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -98,6 +98,10 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into + separate Photoshop Set events, with rename last so named layer references remain valid. + Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); + independent Photoshop replay of the fix is still pending. No readiness percentage or estimate changed. - **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. @@ -287,6 +291,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms), the uncancelled job about 40% faster; #6205 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a2581c4b46..b7a1fbd4c7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (compound layer properties export as separate Photoshop Set events, #8304; percentages unchanged; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -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). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); 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 | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-16 | minor | Content-Aware Move: live result preview for every selection the command takes (TOOL-213-4 done, #6680); percentages unchanged | | 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | From 7da9c62e4ade44e54d695876b0f1543f048b21d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:24:44 +0000 Subject: [PATCH 25/27] Integration: #8190 test that the theme swatch star badge on hover does not move the next swatch --- crates/ui-egui/src/prefs_ui.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 12ae9e960b..af0574ba0b 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3120,6 +3120,27 @@ mod tests { assert_eq!(dark, vec![ThemeKind::Studio]); } + /// The favourite star badge overhangs its swatch; showing it on hover must not move the swatches + /// after it, or the row would shift under the pointer. + #[test] + fn theme_star_badge_on_hover_does_not_move_the_next_swatch() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with_store(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let first = h.get_by_label("Classic").rect(); + let next = h.get_by_label("Adwaita").rect(); + h.hover_at(first.right_top() + vec2(-3.0, 3.0)); + h.run_steps(3); + assert!(h.query_by_label("Add to favorites: Classic").is_some(), "the badge shows while the swatch is hovered"); + assert_eq!(h.get_by_label("Classic").rect(), first); + assert_eq!(h.get_by_label("Adwaita").rect(), next); + } + #[test] fn preferences_interface_shows_language_without_scrolling() { // #2532: in a small window the theme cards filled the page and Language sat out of view From d3cc3a69fb27c509b5ec23a76d5de04b8b9851a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:30:49 +0000 Subject: [PATCH 26/27] Integration: #8344 compare the Photo Filter dictionary as bytes (the field is Vec on main) --- crates/engine/src/atn_cmds/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 9af8a08fbf..351774bd0d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1521,7 +1521,7 @@ fn photo_filter_saves_under_the_name_photoshop_runs() { let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); let step = &file.actions[0].items[0]; assert_eq!(step.event, AtnEvent::Name("photoFilter".into()), "never the code PhtF"); - assert_eq!(step.dictionary, "Photo Filter"); + assert_eq!(step.dictionary, "Photo Filter".as_bytes()); let d = step.descriptor.clone().unwrap(); for key in ["Clr ", "Dnst", "PrsL"] { assert!(d.get(key).is_some(), "the descriptor keeps {key}: {d:?}"); From 1e0532c7ff025abd62148a445635d5952dc4f2f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:31:11 +0000 Subject: [PATCH 27/27] Integration: cargo fmt --- crates/engine/src/presets/tests.rs | 4 +- crates/ui-egui/src/new_doc_ui.rs | 15 +------- crates/ui-egui/src/widgets.rs | 60 ++++++------------------------ 3 files changed, 17 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index de0e23909d..600aef45b6 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -781,7 +781,9 @@ fn a_damaged_preset_set_keeps_the_other_sets() { }); s.execute("file.export.presets.save", json!({"name": "Web JPEG", "settings": export})).unwrap(); let saved = s.presets.to_json(&s); - for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "recent_documents", "custom_shapes", "layer_defaults", "swatches", "export_presets"] { + for key in + ["gradients", "gradient", "styles", "shapes", "pattern_groups", "recent_documents", "custom_shapes", "layer_defaults", "swatches", "export_presets"] + { let mut blob = saved.clone(); blob[key] = json!("not a preset set"); let mut next = Session::new(); diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 1c0747f022..4f09d6a028 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1323,19 +1323,8 @@ mod tests { /// A finger tap at `at`. Events for one phase share a frame (`input_mut`), because `Harness::step` would otherwise run one frame per queued event and drop Touch before the click release (so `second_click` would not see touch). fn touch_tap_at(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { - let touch = |phase| egui::Event::Touch { - device_id: egui::TouchDeviceId(1), - id: egui::TouchId(1), - phase, - pos: at, - force: None, - }; - let button = |pressed| egui::Event::PointerButton { - pos: at, - button: egui::PointerButton::Primary, - pressed, - modifiers: egui::Modifiers::NONE, - }; + let touch = |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos: at, force: None }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; h.input_mut().events.extend([egui::Event::PointerMoved(at), touch(egui::TouchPhase::Start), button(true)]); h.step(); h.input_mut().events.extend([touch(egui::TouchPhase::End), button(false)]); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9ed8827df1..acd15b7029 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1995,19 +1995,8 @@ mod tests { /// One primary press+release at `pos`. When `touch`, emit Touch Start/End with the pointer events (as egui-winit does) so [`super::second_click`] uses the touch distance. fn pointer_click(ctx: &egui::Context, time: f64, pos: egui::Pos2, touch: bool) -> bool { let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); - let button = |pressed| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: egui::Modifiers::NONE, - }; - let touch_ev = |phase| egui::Event::Touch { - device_id: egui::TouchDeviceId(1), - id: egui::TouchId(1), - phase, - pos, - force: None, - }; + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let touch_ev = |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None }; let mut saw = false; for (t, mut events) in [ (time, { @@ -2025,11 +2014,7 @@ mod tests { v }), ] { - let mut raw = egui::RawInput { - time: Some(t), - screen_rect: Some(area), - ..Default::default() - }; + let mut raw = egui::RawInput { time: Some(t), screen_rect: Some(area), ..Default::default() }; raw.events.append(&mut events); let mut out = ctx.run_ui(raw, |ui| { let r = ui.allocate_rect(area, egui::Sense::click()); @@ -2049,16 +2034,9 @@ mod tests { let wide = egui::pos2(50.0 + 48.0, 50.0); // past the old 32 px slop, inside TOUCH_MAX_CLICK_DIST (64) for touch in [false, true] { let ctx = egui::Context::default(); - let mut out = ctx.run_ui( - egui::RawInput { - time: Some(0.0), - screen_rect: Some(area), - ..Default::default() - }, - |ui| { - ui.allocate_rect(area, egui::Sense::click()); - }, - ); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); out.textures_delta.clear(); assert!(!pointer_click(&ctx, 0.1, a, touch), "first click only arms the pair"); let second = pointer_click(&ctx, 0.25, mild, touch); @@ -2066,16 +2044,9 @@ mod tests { } // Touch-only: casual Framework-style drift beyond the previous 32 px budget. let ctx = egui::Context::default(); - let mut out = ctx.run_ui( - egui::RawInput { - time: Some(0.0), - screen_rect: Some(area), - ..Default::default() - }, - |ui| { - ui.allocate_rect(area, egui::Sense::click()); - }, - ); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); out.textures_delta.clear(); assert!(!pointer_click(&ctx, 0.1, a, true)); assert!(pointer_click(&ctx, 0.25, wide, true), "48 px touch jitter is still a double"); @@ -2087,16 +2058,9 @@ mod tests { let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); let p = egui::pos2(40.0, 40.0); let ctx = egui::Context::default(); - let mut out = ctx.run_ui( - egui::RawInput { - time: Some(0.0), - screen_rect: Some(area), - ..Default::default() - }, - |ui| { - ui.allocate_rect(area, egui::Sense::click()); - }, - ); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); out.textures_delta.clear(); assert!(!pointer_click(&ctx, 0.1, p, false)); assert!(!pointer_click(&ctx, 0.1 + super::DOUBLE_CLICK_S + 0.05, p, false), "past Windows' 0.5 s window");