From e4f16fa90c522adb49d5a8d2635a2cad58de9e38 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 16 Oct 2026 16:49:16 -0400 Subject: [PATCH] LR-BEHAV-DIALOGS: own verbs for Auto-Stack and Capture Time, test the button itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-Stack and Capture Time confirms reused the translation keys of a noun ("Stack") and a field label ("Change"), so other languages showed "Stapel" or "変更方法" on the button. They now have their own keys, "Create Stacks" and "Change Time", in all 13 catalogs. Rename Keyword says "Merge Keywords" when the new name already exists, as Rename Person does. The Polish rename help says the file name changes. The confirm label match is exhaustive now (no unreachable "OK" arm). User guides, the Apple Photos note and the parity rows no longer tell people to press OK in Export. Tests read the text painted inside button:dialogOk, reach the plural Export label, cover Auto-Stack and Slideshow Export plurals, Rename Person, smart album Save, and check the German translation of the verbs. Co-Authored-By: Claude --- crates/ui-egui/locales/cs.json | 2 + crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/it.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/ka.json | 2 + crates/ui-egui/locales/pl.json | 4 +- crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/dialogs.rs | 21 +++- crates/ui-egui/src/tests/dialog_copy.rs | 138 +++++++++++++++++------- docs/apple-photos.md | 2 +- docs/parity-checklist.md | 4 +- docs/user/guides/export-photos.md | 4 +- docs/whats-new.md | 2 +- 19 files changed, 151 insertions(+), 48 deletions(-) diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index a11a6819e..2b6b74328 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -501,6 +501,8 @@ "Cross Process": "Křížové vyvolání", "Culling": "Třídění", "Cull": "Roztřídit", + "Create Stacks": "Vytvořit sady", + "Change Time": "Změnit čas", "Curve": "Křivka", "Custom": "Vlastní", "Custom template…": "Vlastní šablona…", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index dcc8cf4e6..241593c1b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -362,6 +362,8 @@ "Cross Process": "Crossentwicklung", "Culling": "Fotoauswahl", "Cull": "Auswählen", + "Create Stacks": "Stapel erstellen", + "Change Time": "Zeit ändern", "Curve": "Kurve", "Custom": "Benutzerdefiniert", "Custom template…": "Benutzerdefinierte Vorlage…", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 91b267b77..c231205ff 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -352,6 +352,8 @@ "Cross Process": "Proceso cruzado", "Culling": "Selección", "Cull": "Seleccionar", + "Create Stacks": "Crear pilas", + "Change Time": "Cambiar hora", "Curve": "Curva", "Custom": "Personalizado", "Custom template…": "Plantilla personalizada…", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b27e83c0f..de52d2b01 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -335,6 +335,8 @@ "Cross Process": "Traitement croisé", "Culling": "Tri", "Cull": "Trier", + "Create Stacks": "Créer les piles", + "Change Time": "Changer l'heure", "Curve": "Courbe", "Custom": "Personnalisé", "Custom template…": "Modèle personnalisé…", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 0bb2399f9..e0e2cc641 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -336,6 +336,8 @@ "Cross Process": "Processo incrociato", "Culling": "Selezione", "Cull": "Seleziona", + "Create Stacks": "Crea pile", + "Change Time": "Cambia ora", "Curve": "Curva", "Custom": "Personalizzato", "Custom template…": "Modello personalizzato…", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 88eeac49a..72aa1f968 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -326,6 +326,8 @@ "Cross Process": "クロスプロセス", "Culling": "写真の選別", "Cull": "選別", + "Create Stacks": "スタックを作成", + "Change Time": "時刻を変更", "Curve": "カーブ", "Custom": "カスタム", "Custom template…": "カスタムテンプレート…", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 5a83dfd2d..ee7095037 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -509,6 +509,8 @@ "Cross Process": "ჯვარედინი დამუშავება", "Culling": "გადარჩევა", "Cull": "გადარჩევა", + "Create Stacks": "დასტების შექმნა", + "Change Time": "დროის შეცვლა", "Culling.": "გადარჩევა.", "Curve": "მრუდი", "Custom": "მორგებული", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 6d0184306..2f79795d6 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -324,6 +324,8 @@ "Cross Process": "Obróbka krzyżowa", "Culling": "Selekcja zdjęć", "Cull": "Selekcjonuj", + "Create Stacks": "Utwórz stosy", + "Change Time": "Zmień czas", "Curve": "Krzywa", "Custom": "Niestandardowe", "Custom template…": "Szablon niestandardowy…", @@ -1942,7 +1944,7 @@ "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne", "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,", "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do", - "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Każdy plik jest zmieniany na dysku razem ze swoim plikiem XMP; zajęta nazwa dostaje przyrostek -1, -2…", + "The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Nazwa każdego pliku na dysku jest zmieniana razem z jego plikiem XMP; zajęta nazwa dostaje przyrostek -1, -2…", "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON", "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 64a0d09e7..6ffe1c34c 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -343,6 +343,8 @@ "Cross Process": "Processamento cruzado", "Culling": "Triagem", "Cull": "Fazer triagem", + "Create Stacks": "Criar pilhas", + "Change Time": "Alterar hora", "Curve": "Curva", "Custom": "Personalizado", "Custom template…": "Modelo personalizado…", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b6d78e3f8..c5278f68d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -289,6 +289,8 @@ "Cross Process": "Кросс-процесс", "Culling": "Отбор", "Cull": "Отобрать", + "Create Stacks": "Создать стопки", + "Change Time": "Изменить время", "Curve": "Кривая", "Custom": "Свои", "Custom…": "Свои…", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 04d27da37..0b1c0bcd8 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -617,6 +617,8 @@ "Crop, Rotate, Geometry": "Кадрування, обертання, геометрія", "Culling": "Відбір", "Cull": "Відібрати", + "Create Stacks": "Створити стоси", + "Change Time": "Змінити час", "Custom template…": "Власний шаблон…", "Cycle Crop Overlay": "Перемкнути накладання кадрування", "Cycle Crop Overlay Orientation": "Перемкнути орієнтацію накладання кадрування", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d64faddbe..47670c769 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -340,6 +340,8 @@ "Cross Process": "交叉冲洗", "Culling": "挑片", "Cull": "筛选", + "Create Stacks": "创建堆叠", + "Change Time": "更改时间", "Curve": "曲线", "Custom": "自定义", "Custom template…": "自定义模板…", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 2b0e7167c..a8a547c9b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -341,6 +341,8 @@ "Cross Process": "交叉沖洗", "Culling": "挑片", "Cull": "開始挑片", + "Create Stacks": "建立堆疊", + "Change Time": "變更時間", "Curve": "曲線", "Custom": "自訂", "Custom template…": "自訂範本…", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 12ffcf64e..876787c4d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -355,9 +355,12 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta | Dialog::SmartRules { id: None, .. } | Dialog::CreatePreset { .. } => "Create", Dialog::SmartRules { .. } | Dialog::LabelNames { .. } => "Save", + Dialog::RenameKeyword { from, to } if app.session.catalog.has_keyword(to) && !lightcraft_catalog::keywords::same(from, to) => { + "Merge Keywords" + } Dialog::RenameAlbum { .. } | Dialog::Rename { .. } | Dialog::RenameKeyword { .. } => "Rename", - Dialog::AutoStack { .. } => "Stack", - Dialog::CaptureTime { .. } => "Change", + Dialog::AutoStack { .. } => "Create Stacks", + Dialog::CaptureTime { .. } => "Change Time", Dialog::Cull { .. } => "Cull", Dialog::CopySettings { .. } => "Copy", Dialog::PasteSettings { .. } => "Paste", @@ -376,9 +379,17 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta Dialog::SamModel { .. } => return sam_ok_label(dlg, sam).to_string(), Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", Dialog::RenamePerson { .. } => "Rename", - _ if informational => "Close", - // only a one-field prompt opened with a command that has no verb here - _ => "OK", + // the dialogs that only show something, or a model file that can't be used + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + | Dialog::ViewOptions + | Dialog::LightroomResult { .. } + | Dialog::FaceModel { .. } => "Close", }; label.to_string() } diff --git a/crates/ui-egui/src/tests/dialog_copy.rs b/crates/ui-egui/src/tests/dialog_copy.rs index 7424aabce..6e6f7209f 100644 --- a/crates/ui-egui/src/tests/dialog_copy.rs +++ b/crates/ui-egui/src/tests/dialog_copy.rs @@ -1,5 +1,6 @@ -//! A dialog's confirm button names what it does ("Rename", "Create", "Stack"…), not "OK": the -//! button is the last thing read before a change is made. +//! A dialog's confirm button names what it does ("Rename", "Create", "Create Stacks"…), not "OK": +//! the button is the last thing read before a change is made. Every check reads the text painted +//! inside the `button:dialogOk` widget itself, not just any text in the window. use crate::headless::Headless; use crate::state::Dialog; @@ -16,34 +17,57 @@ fn demo() -> Headless { h } -fn show(h: &mut Headless, dialog: Dialog) -> Vec { +fn show(h: &mut Headless, dialog: Dialog) { h.app.ui.dialog = Some(dialog); h.step(); h.step(); - h.painted_text() } fn dialog(value: serde_json::Value) -> Dialog { serde_json::from_value(value).unwrap_or_else(|e| panic!("bad dialog json: {e}")) } -/// Dialogs whose confirm may stay "OK" because nothing more specific is true of them: none today. -/// (A one-field prompt opened with a command the app doesn't know has no verb to name.) -const KEEPS_OK: &[&str] = &[]; +/// The text painted inside the confirm button. +fn confirm_label(h: &Headless) -> String { + let rect = h.app.widgets.iter().find(|(w, _)| w == "button:dialogOk").map(|(_, r)| *r).unwrap_or_else(|| panic!("no confirm button")); + h.view + .painted_shapes() + .into_iter() + .filter_map(|s| match s { + egui::epaint::Shape::Text(t) if rect.contains(t.pos + t.galley.rect.center().to_vec2()) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect::>() + .join("") +} + +fn select(h: &mut Headless, n: usize) { + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(n).collect(); + h.app.session.selection.ids = ids; +} + +fn text_with(h: &Headless, start: &str) -> String { + h.painted_text().into_iter().find(|t| t.starts_with(start)).unwrap_or_else(|| panic!("no text starting {start:?}")) +} #[test] fn confirm_buttons_name_their_action() { let mut h = demo(); + h.app + .run("photo.setMeta", json!({"ids": [h.app.session.catalog.photos().next().map(|p| p.id.0).unwrap_or(0)], "addKeywords": ["taken"]})) + .unwrap(); let cases: Vec<(&str, serde_json::Value, &str)> = vec![ ("new album", json!({"kind": "newAlbum", "name": "", "folder": false}), "Create"), ("new folder", json!({"kind": "newAlbum", "name": "", "folder": true}), "Create"), ("rename album", json!({"kind": "renameAlbum", "id": 1, "name": "A"}), "Rename"), ("new smart album", json!({"kind": "newSmartAlbum", "name": ""}), "Create"), - ("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Stack"), - ("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change"), + ("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Create Stacks"), + ("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change Time"), ("label names", json!({"kind": "labelNames", "names": [], "save_as": ""}), "Save"), ("rename photos", json!({"kind": "rename", "template": "{name}", "start": 1}), "Rename"), ("rename keyword", json!({"kind": "renameKeyword", "from": "a", "to": "b"}), "Rename"), + ("rename keyword onto an existing one", json!({"kind": "renameKeyword", "from": "a", "to": "taken"}), "Merge Keywords"), + ("rename person", json!({"kind": "renamePerson", "from": "Ann", "to": "Anna", "merge": false}), "Rename"), ("merge keywords", json!({"kind": "mergeKeywords", "from": ["a"], "into": "b"}), "Merge Keywords"), ("assisted culling", json!({"kind": "cull", "reject_below": 0.0, "pick_best": true, "burst_gap": 10.0, "similarity": 0.93}), "Cull"), ("new preset", json!({"kind": "createPreset", "name": "", "group": ""}), "Create"), @@ -51,9 +75,14 @@ fn confirm_buttons_name_their_action() { ("paste settings", json!({"kind": "pasteSettings", "groups": []}), "Paste"), ]; for (what, value, want) in cases { - let text = show(&mut h, dialog(value)); - assert!(text.iter().any(|t| t == want), "{what}: the confirm button reads {want:?}: {text:?}"); - assert!(KEEPS_OK.contains(&what) || !text.iter().any(|t| t == "OK"), "{what}: the confirm button still reads OK: {text:?}"); + show(&mut h, dialog(value)); + assert_eq!(confirm_label(&h), want, "{what}"); + } + // a new smart album's rules are created; an existing one's are saved + for (id, want) in [(None, "Create"), (Some(1), "Save")] { + let d = Dialog::SmartRules { id, name: "A".into(), rules: Default::default(), parent: None }; + show(&mut h, d); + assert_eq!(confirm_label(&h), want, "smart album rules {id:?}"); } } @@ -85,39 +114,76 @@ fn one_field_prompts_name_what_they_do() { params: json!({}), key: "name".into(), }; - let text = show(&mut h, prompt); - assert!(text.iter().any(|t| t == want), "{command}: the confirm button reads {want:?}: {text:?}"); - assert!(!text.iter().any(|t| t == "OK"), "{command}: no OK: {text:?}"); + show(&mut h, prompt); + assert_eq!(confirm_label(&h), want, "{command}"); } } #[test] fn export_confirm_says_how_many_photos() { let mut h = demo(); - h.app.run("dialog.export", json!({})).unwrap(); - h.step(); - h.step(); - let one = h.painted_text(); - let n = h.app.session.selection.ids.len().max(1); - let want = if n == 1 { "Export 1 Photo".to_string() } else { format!("Export {n} Photos") }; - assert!(one.contains(&want), "reads {want:?}: {one:?}"); - assert!(!one.iter().any(|t| t == "OK")); + for (n, want) in [(1, "Export 1 Photo"), (3, "Export 3 Photos")] { + select(&mut h, n); + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(confirm_label(&h), want); + } +} + +#[test] +fn the_confirm_is_the_verb_in_another_language() { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::i18n::set_language(crate::i18n::Locale::En); + } + } + let _restore = Restore; + let mut h = demo(); + h.app.ui.language = crate::i18n::Locale::De; + show(&mut h, dialog(json!({"kind": "autoStack", "gap": 60.0}))); + assert_eq!(confirm_label(&h), "Stapel erstellen"); + show(&mut h, dialog(json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}))); + assert_eq!(confirm_label(&h), "Zeit ändern"); + show(&mut h, dialog(json!({"kind": "renameAlbum", "id": 1, "name": "A"}))); + assert_eq!(confirm_label(&h), "Umbenennen"); +} + +/// "N noun(s)" in `text`: the number before `noun` and whether `noun` is written in the plural. +fn count_before(text: &str, noun: &str) -> (u64, bool) { + let at = text.find(noun).unwrap_or_else(|| panic!("{text:?} has no {noun:?}")); + let n = text[..at].trim_end().rsplit(' ').next().and_then(|w| w.parse().ok()).unwrap_or_else(|| panic!("no number before {noun:?} in {text:?}")); + (n, text[at + noun.len()..].starts_with('s')) } #[test] fn counts_agree_with_their_nouns() { let mut h = demo(); - let ids: Vec = h.app.session.catalog.photos().map(|p| p.id.0).take(2).collect(); - let rows = |h: &mut Headless, n: usize| { - h.app.session.selection.ids = ids.iter().take(n).map(|i| lightcraft_catalog::PhotoId(*i)).collect(); - let text = show( - h, - dialog(json!({"kind": "contactSheet", "options": serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap()})), - ); - text.into_iter().find(|t| t.contains("selected photo") || t.contains("1 photo")).unwrap_or_default() - }; - let one = rows(&mut h, 1); - assert!(one.starts_with("1 selected photo ·") && one.ends_with("1 page"), "{one:?}"); - let two = rows(&mut h, 2); - assert!(two.starts_with("2 selected photos ·") && two.ends_with("1 page"), "{two:?}"); + // contact sheet: one photo, then two + for n in [1usize, 2] { + select(&mut h, n); + let options = serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap(); + show(&mut h, dialog(json!({"kind": "contactSheet", "options": options}))); + let text = text_with(&h, &format!("{n} selected photo")); + assert_eq!(text, if n == 1 { "1 selected photo · 1 page" } else { "2 selected photos · 1 page" }); + } + // auto-stack: a tiny gap and a huge one give different counts; each agrees with its noun + for gap in [1.0, 86400.0] { + show(&mut h, dialog(json!({"kind": "autoStack", "gap": gap}))); + let text = text_with(&h, "Creates "); + let (stacks, plural) = count_before(&text, " stack"); + assert_eq!(plural, stacks != 1, "{text:?}"); + } + // slideshow export: photos and slides + show( + &mut h, + dialog( + json!({"kind": "slideshowExport", "settings": serde_json::to_value(lightcraft_engine::slideshow_export::ExportSettings::default()).unwrap()}), + ), + ); + let text = h.painted_text().into_iter().find(|t| t.contains("in view")).unwrap_or_default(); + let (photos, p1) = count_before(&text, " photo"); + let (slides, p2) = count_before(&text, " slide"); + assert_eq!((p1, p2), (photos != 1, slides != 1), "{text:?}"); } diff --git a/docs/apple-photos.md b/docs/apple-photos.md index dfc0f4389..b95db2e25 100644 --- a/docs/apple-photos.md +++ b/docs/apple-photos.md @@ -130,7 +130,7 @@ new empty library first: hold ⌥ while opening Photos ▸ Create New). 2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running the binary in a terminal, which makes the terminal the app macOS asks about). 3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add - to Apple Photos**, type the album `LightCraft test "1"` and press OK. + to Apple Photos**, type the album `LightCraft test "1"` and press Export. 4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it. Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”", and a top-level album of that name holds the three images. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 08acec306..1eb74405b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -389,7 +389,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | +| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / Export stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | @@ -468,7 +468,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels.rs` | | -| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen (the confirm button names its action: Create, Rename, Stack, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) | +| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / confirm row stay on screen (the confirm button names its action: Create, Rename, Create Stacks, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests/panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target | | LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests/panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | | LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menus/menubar.rs`, `crates/ui-egui/src/tests/panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden | diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md index 71bca3241..c484a4696 100644 --- a/docs/user/guides/export-photos.md +++ b/docs/user/guides/export-photos.md @@ -5,7 +5,7 @@ order: 40 verified: 0.7.1 --- -Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **OK**. Your originals are not changed. +Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **Export** (the button also says how many photos, such as **Export 3 Photos**). Your originals are not changed. ## Steps @@ -20,7 +20,7 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a 9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. 10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. 11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. -12. Click **OK**. The export shows its progress in the activity stack at the top left. +12. Click **Export** (such as **Export 3 Photos**). The export shows its progress in the activity stack at the top left. If you did not choose a folder, the window stays open and shows an error, so your choices are not lost. diff --git a/docs/whats-new.md b/docs/whats-new.md index 79cee19d5..d8c79602e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -159,7 +159,7 @@ ### Dialog buttons say what they do - The confirm button in dialogs now names its action instead of "OK": Create for a new album, smart album, folder or - preset, Rename for photos, albums, keywords, people and presets, Stack for Auto-Stack, Change for Capture Time, + preset, Rename for photos, albums, keywords, people and presets, Create Stacks for Auto-Stack, Change Time for Capture Time, Copy and Paste for settings, and Export 12 Photos in the Export dialog. Rename Photos now explains what its Tags button lists and what happens on disk, instead of pointing at the button. Contact Sheet, Auto-Stack and Slideshow Export no longer say "1 photos" or "1 stacks".