From 17a9e97a38f27b7d418e5a5c760b1a2f9f253b7f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 27 Oct 2026 09:49:26 -0400 Subject: [PATCH 1/3] LR-BEHAV-DIALOGS: a one-line prompt that's refused stays open and says why A refused Rename Folder, New Folder on Disk or other name prompt closed without a word. It now stays open with the typed text and toasts the engine's message. A New Folder on Disk that works says where the folder is. --- crates/ui-egui/locales/cs-formats.json | 1 + crates/ui-egui/locales/de-formats.json | 1 + crates/ui-egui/locales/es-formats.json | 1 + crates/ui-egui/locales/fi-formats.json | 1 + crates/ui-egui/locales/fr-formats.json | 1 + crates/ui-egui/locales/id-formats.json | 1 + crates/ui-egui/locales/it-formats.json | 1 + crates/ui-egui/locales/ja-formats.json | 1 + crates/ui-egui/locales/ka-formats.json | 1 + crates/ui-egui/locales/pl-formats.json | 1 + crates/ui-egui/locales/pt-br-formats.json | 1 + crates/ui-egui/locales/ru-formats.json | 1 + crates/ui-egui/locales/sv-formats.json | 1 + crates/ui-egui/locales/uk-formats.json | 1 + crates/ui-egui/locales/vi-formats.json | 1 + crates/ui-egui/locales/zh-hans-formats.json | 1 + crates/ui-egui/locales/zh-hant-formats.json | 1 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 10 ++ crates/ui-egui/src/dialogs/confirm.rs | 9 ++ crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/panels/left/folders.rs | 7 + crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/text_prompt.rs | 140 ++++++++++++++++++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 8 ++ 26 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/tests/text_prompt.rs diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 1eb882f09..fea5f27a0 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -108,6 +108,7 @@ "Moved; {} photo(s) relinked": "Přesunuto; znovu propojeno fotografií: {}", "Named ({named_n})": "Pojmenované ({named_n})", "New Folder in “{name}”": "Nová složka v „{name}“", + "Created folder “{name}” in “{parent}”": "Vytvořena složka „{name}“ v „{parent}“", "On {have} of {of} selected photos": "Na {have} z {of} vybraných fotografií", "Painting “{k}”: click photos to add or remove it · Esc stops": "Nanášení „{k}“: kliknutím na fotografie přidáte nebo odeberete slovo · Esc ukončí režim", "Paste into {n} photo{} — only settings that were copied are pasted": "Vložit do fotografií: {n}{:.0} — vloží se pouze zkopírovaná nastavení", diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 79390f136..bfec3e200 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -248,6 +248,7 @@ "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)", "New Folder in “{name}”": "Neuer Ordner in „{name}“", + "Created folder “{name}” in “{parent}”": "Ordner „{name}“ in „{parent}“ erstellt", "Moved {} photo(s)": "{} Foto(s) verschoben", "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 33e5f52fc..3305afac6 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -248,6 +248,7 @@ "{what} merge cancelled": "Combinación {what} cancelada", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)", "New Folder in “{name}”": "Nueva carpeta en «{name}»", + "Created folder “{name}” in “{parent}”": "Carpeta «{name}» creada en «{parent}»", "Moved {} photo(s)": "{} foto(s) movida(s)", "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index 1daab3dab..ddbe84e3e 100644 --- a/crates/ui-egui/locales/fi-formats.json +++ b/crates/ui-egui/locales/fi-formats.json @@ -134,6 +134,7 @@ "Moved; {} photo(s) relinked": "Siirretty; yhdistetty uudelleen kuvia: {}", "Named ({named_n})": "Nimetyt ({named_n})", "New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”", + "Created folder “{name}” in “{parent}”": "Kansio ”{name}” luotu kohteeseen ”{parent}”", "On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla", "Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa", "Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 294273781..7b89707a7 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -248,6 +248,7 @@ "{what} merge cancelled": "Fusion {what} annulée", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)", "New Folder in “{name}”": "Nouveau dossier dans « {name} »", + "Created folder “{name}” in “{parent}”": "Dossier « {name} » créé dans « {parent} »", "Moved {} photo(s)": "{} photo(s) déplacée(s)", "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index 7fc325522..1ffc9978e 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -251,6 +251,7 @@ "{what} merge cancelled": "Penggabungan {what} dibatalkan", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} foto Lokal{:.0} dilewati: album hanya dapat berisi foto pustaka (tambahkan ke Foto saya terlebih dahulu)", "New Folder in “{name}”": "Folder baru di “{name}”", + "Created folder “{name}” in “{parent}”": "Folder “{name}” dibuat di “{parent}”", "Moved {} photo(s)": "{} foto dipindahkan", "not added to Apple Photos: {e}": "tidak ditambahkan ke Apple Photos: {e}", "{n} added to Apple Photos album “{album}”": "{n} ditambahkan ke album Apple Photos “{album}”", diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 7efae90e9..3dca09466 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -240,6 +240,7 @@ "{what} merge cancelled": "Unione {what} annullata", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)", "New Folder in “{name}”": "Nuova cartella in “{name}”", + "Created folder “{name}” in “{parent}”": "Cartella “{name}” creata in “{parent}”", "Moved {} photo(s)": "Foto spostate: {}", "not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}", "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 99c14bf16..41552528d 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -252,6 +252,7 @@ "{what} merge cancelled": "{what}合成をキャンセルしました", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)", "New Folder in “{name}”": "「{name}」に新規フォルダ", + "Created folder “{name}” in “{parent}”": "「{parent}」にフォルダ「{name}」を作成しました", "Moved {} photo(s)": "{} 枚の写真を移動しました", "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index 460368261..58d6952e7 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -108,6 +108,7 @@ "Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}", "Named ({named_n})": "სახელიანი ({named_n})", "New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში", + "Created folder “{name}” in “{parent}”": "საქაღალდე „{name}“ შეიქმნა „{parent}“-ში", "On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე", "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს", "Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 20c7f840d..784d31fd1 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -236,6 +236,7 @@ "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometria z Lightrooma dla {path} nie została zachowana; tryb {mode} zostanie obliczony ponownie.", "Moved {} photo(s)": "Przeniesione zdjęcia: {}", "New Folder in “{name}”": "Nowy folder w „{name}”", + "Created folder “{name}” in “{parent}”": "Utworzono folder „{name}” w „{parent}”", "Restored {} photo{} from Recently Deleted": "Przywrócone z Ostatnio usuniętych zdjęcia: {}{:.0}", "XMP adjustments were not imported for {path}: {fields}.": "Korekty XMP dla {path} nie zostały zaimportowane: {fields}.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}” nie został zastosowany do {path}; kolory mogą się różnić od Lightrooma.", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index f17f2c0fd..4da99c3ed 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -252,6 +252,7 @@ "{what} merge cancelled": "{what} mesclagem cancelada", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)", "New Folder in “{name}”": "Nova pasta em “{name}”", + "Created folder “{name}” in “{parent}”": "Pasta “{name}” criada em “{parent}”", "Moved {} photo(s)": "{} foto(s) movida(s)", "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 76b7af427..0344a2814 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -248,6 +248,7 @@ "{what} merge cancelled": "{what}: слияние отменено", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)", "New Folder in “{name}”": "Новая папка в «{name}»", + "Created folder “{name}” in “{parent}”": "Папка «{name}» создана в «{parent}»", "Moved {} photo(s)": "Перемещено фото: {}", "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 9a71a0288..958cde6f0 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -127,6 +127,7 @@ "Moved; {} photo(s) relinked": "Flyttat; {} foto(n) har länkats om", "Named ({named_n})": "Namngivna ({named_n})", "New Folder in “{name}”": "Ny mapp i ”{name}”", + "Created folder “{name}” in “{parent}”": "Mappen ”{name}” skapad i ”{parent}”", "new, inside {parent}": "ny, inuti {parent}", "none (CPU rendering): {why}": "ingen (CPU-rendering): {why}", "not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 36022ac1a..068b9b09b 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -248,6 +248,7 @@ "{what} merge cancelled": "Об’єднання {what} скасовано", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)", "New Folder in “{name}”": "Нова тека в «{name}»", + "Created folder “{name}” in “{parent}”": "Теку «{name}» створено в «{parent}»", "Moved {} photo(s)": "Переміщено фото: {}", "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index d5e28bff0..3405a3f9f 100644 --- a/crates/ui-egui/locales/vi-formats.json +++ b/crates/ui-egui/locales/vi-formats.json @@ -134,6 +134,7 @@ "Moved; {} photo(s) relinked": "Đã di chuyển; liên kết lại {} ảnh", "Named ({named_n})": "Có tên ({named_n})", "New Folder in “{name}”": "Thư mục mới trong «{name}»", + "Created folder “{name}” in “{parent}”": "Đã tạo thư mục «{name}» trong «{parent}»", "On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn", "Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng", "Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 7406c39b8..f612c41ac 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -252,6 +252,7 @@ "{what} merge cancelled": "已取消 {what} 合并", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)", "New Folder in “{name}”": "在“{name}”中新建文件夹", + "Created folder “{name}” in “{parent}”": "已在“{parent}”中创建文件夹“{name}”", "Moved {} photo(s)": "已移动 {} 张照片", "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index d5c4eb279..bff14d687 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -252,6 +252,7 @@ "{what} merge cancelled": "已取消{what}合併", "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)", "New Folder in “{name}”": "在「{name}」中新增資料夾", + "Created folder “{name}” in “{parent}”": "已在「{parent}」中建立資料夾「{name}」", "Moved {} photo(s)": "已移動 {} 張照片", "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index caacbc590..f53ee0f31 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,7 +414,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.confirm" => match app.ui.dialog.take() { Some(d) => { let r = crate::panels::dialogs::confirm_dialog(app, &d); - // the import review and the rule editor stay open on an error, as with their buttons + // the import review, the rule editor and the one-line prompts stay open on an error, as with their buttons if (r.is_err() && matches!( d, @@ -423,6 +423,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest | crate::state::Dialog::SmartRules { .. } | crate::state::Dialog::SaveEffect { .. } | crate::state::Dialog::CaptureTime { .. } + | crate::state::Dialog::TextPrompt { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) || (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d)) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 646288220..fc4d06afe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -470,6 +470,16 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> *error = Some(e); } } + // a one-line prompt that is refused stays open with its text, so the name can be fixed + Err(e) if matches!(dlg, Dialog::TextPrompt { .. }) => app.toast_error(ctx, e), + // a folder made on disk says so (the new folder holds no photos yet) + Ok(r) if matches!(&dlg, Dialog::TextPrompt { command, .. } if command == "folder.create") => { + if let Dialog::TextPrompt { params, .. } = &dlg { + let (path, parent) = (r["path"].as_str().unwrap_or_default(), params["parent"].as_str().unwrap_or_default()); + app.toast(ctx, crate::panels::left::created_text(path, parent)); + } + closed = true; + } Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { // albums hold library photos (issue #684): say which part of the selection stayed out crate::panels::grid::toast_skipped(app, ctx, &r); diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 6b8998c33..8891c1b55 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -55,6 +55,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { + // Rename Folder opens with the folder's own name typed: confirming it unchanged renames nothing, as it always has + // (the engine would refuse it as a name that "already exists") + let kept = command == "folder.rename" + && params["path"].as_str().is_some_and(|path| { + std::path::Path::new(path.trim_end_matches(['/', '\\'])).file_name().is_some_and(|f| f.to_string_lossy() == value.trim()) + }); + if kept { + return Ok(serde_json::Value::Null); + } let mut p = params.clone(); p[key.as_str()] = json!(value.trim()); app.run(command, p) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 27b86aa0d..57083caf5 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -23,7 +23,7 @@ pub use albums::{album_drag_feedback, auto_scroll_speed}; use albums::{albums_rows, drag_auto_scroll}; pub(crate) use dates::browse_all_photos; use dates::date_rows; -pub(crate) use folders::{folder_row_of, move_photos, moved_text}; +pub(crate) use folders::{created_text, folder_row_of, move_photos, moved_text}; use folders::{folder_rows, reveal_chosen}; use keywords::keyword_rows; pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 20c1bbce1..e57fb6711 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -484,6 +484,13 @@ pub(crate) fn moved_text(n: u64, name: &str) -> String { } } +/// What `folder.create` of the folder at `path` inside `parent` says when it succeeds: the folder +/// is on disk now, but it has no photos, so the Folders list may not show it yet. +pub(crate) fn created_text(path: &str, parent: &str) -> String { + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |f| f.to_string_lossy().to_string()); + crate::i18n::tr_format!("Created folder “{name}” in “{parent}”", name = name, parent = lightcraft_catalog::folders::folder_label(parent)) +} + /// [`move_photos`], saying how it went in a toast. fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { match move_photos(app, ids, path) { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 3a102a29a..20de455e7 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -75,6 +75,7 @@ mod switch_library; mod sync; mod tallies; mod temp_slider; +mod text_prompt; mod titlebar; mod tool_overlay; mod unsaved; diff --git a/crates/ui-egui/src/tests/text_prompt.rs b/crates/ui-egui/src/tests/text_prompt.rs new file mode 100644 index 000000000..c089b2430 --- /dev/null +++ b/crates/ui-egui/src/tests/text_prompt.rs @@ -0,0 +1,140 @@ +//! A one-line prompt that the engine refuses stays open with its text and says why, so the name can +//! be fixed; a folder made on disk says it was made. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(60); +const CREATED: &str = "Created folder “{name}” in “{parent}”"; + +/// A fresh `Shoot` folder holding `Taken` and `Other`, in a temporary folder of its own. +fn shoot(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("lc-text-prompt-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let shoot = root.join("Shoot"); + for name in ["Taken", "Other"] { + std::fs::create_dir_all(shoot.join(name)).unwrap(); + } + shoot +} + +/// Remove the temporary folder `shoot` made, which holds `dir`. +fn clean(dir: &std::path::Path) { + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); +} + +fn app() -> Headless { + let session = lightcraft_engine::Session::new().with_fs(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0); + h.settle(SETTLE); + h +} + +fn prompt(command: &str, value: &str, params: serde_json::Value, key: &str) -> Dialog { + Dialog::TextPrompt { title: "Title".into(), hint: "Name".into(), value: value.into(), command: command.into(), params, key: key.into() } +} + +/// Open `dialog`, press its OK button, and let the frame settle. +fn confirm(h: &mut Headless, dialog: Dialog) { + h.app.ui.dialog = Some(dialog); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +fn assert_stays_open(h: &Headless, value: &str, reason: &str) { + match &h.app.ui.dialog { + Some(Dialog::TextPrompt { value: kept, .. }) => assert_eq!(kept, value, "the typed text is kept"), + other => panic!("the prompt stays open: {other:?}"), + } + assert!(toast(h).contains(reason), "the reason is shown: {:?}", toast(h)); +} + +#[test] +fn a_new_folder_whose_name_is_taken_keeps_the_prompt_open_and_says_why() { + let dir = shoot("create-taken"); + let mut h = app(); + confirm(&mut h, prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name")); + assert_stays_open(&h, "Taken", "already exists"); + clean(&dir); +} + +#[test] +fn a_rename_to_a_taken_name_keeps_the_prompt_open_and_says_why() { + let dir = shoot("rename-taken"); + let mut h = app(); + confirm(&mut h, prompt("folder.rename", "Taken", json!({"path": dir.join("Other").to_string_lossy()}), "name")); + assert_stays_open(&h, "Taken", "already exists"); + assert!(dir.join("Other").is_dir(), "nothing was renamed"); + clean(&dir); +} + +#[test] +fn a_rename_that_keeps_the_name_closes_the_prompt_and_changes_nothing() { + let dir = shoot("rename-same"); + let mut h = app(); + // Rename Folder opens with the folder's own name typed + confirm(&mut h, prompt("folder.rename", "Other", json!({"path": dir.join("Other").to_string_lossy()}), "name")); + assert_eq!(h.app.ui.dialog, None, "the prompt closes"); + assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h)); + assert!(dir.join("Other").is_dir(), "nothing was renamed"); + clean(&dir); +} + +#[test] +fn the_control_channel_keeps_a_refused_prompt_open_too() { + let dir = shoot("control-taken"); + let mut h = app(); + h.app.ui.dialog = Some(prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(h.settle(SETTLE), "the prompt renders settled"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_ne!(r["ok"], true, "the name is refused: {r}"); + assert!(r.to_string().contains("already exists"), "{r}"); + match &h.app.ui.dialog { + Some(Dialog::TextPrompt { value, .. }) => assert_eq!(value, "Taken", "the typed text is kept"), + other => panic!("the prompt stays open: {other:?}"), + } + clean(&dir); +} + +#[test] +fn a_new_folder_closes_the_prompt_and_says_where_it_is() { + let dir = shoot("create-ok"); + let mut h = app(); + confirm(&mut h, prompt("folder.create", " Selects ", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(dir.join("Selects").is_dir(), "made on disk"); + assert_eq!(h.app.ui.dialog, None); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent)); + clean(&dir); +} + +#[test] +fn the_created_folder_toast_comes_through_the_catalog() { + let dir = shoot("create-de"); + let path = dir.join("Selects"); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + // the catalog's own value (formats have no `Locale` accessor); a language without one shows the English wording + let catalog: std::collections::BTreeMap = serde_json::from_str(include_str!("../../locales/de-formats.json")).unwrap(); + let own = catalog.get(CREATED).filter(|text| !text.is_empty()).map_or(CREATED, String::as_str); + let want = own.replace("{name}", "Selects").replace("{parent}", &parent); + crate::i18n::set_language(crate::i18n::Locale::De); + let text = crate::panels::left::created_text(&path.to_string_lossy(), &dir.to_string_lossy()); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!(text, want); + clean(&dir); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..2739a6572 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -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/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; 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/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; 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`); a one-line prompt (Rename Folder, New Folder on Disk, the preset and version names, New Library when its name can't be used, Auto-Tag from Tracklog) the engine refuses stays open with its text and shows the reason in a toast, so the name can be corrected, where it used to close without a word; a New Folder on Disk that works says so (“Created folder “Selects” in “2026/Shoot””, naming the parent by its last two folders as dialogs do) | | 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 | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-27 | patch | LR-BEHAV-DIALOGS: a one-line prompt the engine refuses (a name already taken, a bad time zone) stays open with the typed text and shows the reason, instead of closing silently; a New Folder on Disk that works shows “Created folder “…” in “…””; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..cea9f72ed 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-27 + +### A refused name prompt stays open and says why +- A one-line prompt (Rename Folder, New Folder on Disk, the preset and version names, New Library for a name it can't + use, Auto-Tag from Tracklog) that the app refuses, for example a folder name that is taken, now stays open with what + you typed and shows the reason, so you can change the name. Before, it closed without a word. A New Folder on Disk + that works now says so: “Created folder “Selects” in “2026/Shoot””. Cancel and Esc still close the prompt. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From d59726393f22485f66e1719447c2f9c02bd9893d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 13:05:53 -0400 Subject: [PATCH 2/3] LR-BEHAV-DIALOGS: a Rename Folder prompt confirmed unchanged keeps a name with spaces at its ends, and ui.dialog.confirm shows why a prompt was refused --- crates/ui-egui/src/control.rs | 4 ++++ crates/ui-egui/src/dialogs/confirm.rs | 7 +++++-- crates/ui-egui/src/tests/text_prompt.rs | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f53ee0f31..edf583b5f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,6 +414,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.confirm" => match app.ui.dialog.take() { Some(d) => { let r = crate::panels::dialogs::confirm_dialog(app, &d); + // a refused one-line prompt says why on screen, as with its button + if let (Err(e), crate::state::Dialog::TextPrompt { .. }) = (&r, &d) { + app.toast_error(ctx, e.clone()); + } // the import review, the rule editor and the one-line prompts stay open on an error, as with their buttons if (r.is_err() && matches!( diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 8891c1b55..fdb90055d 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -56,10 +56,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { // Rename Folder opens with the folder's own name typed: confirming it unchanged renames nothing, as it always has - // (the engine would refuse it as a name that "already exists") + // (the engine would refuse it as a name that "already exists"). A name with spaces at its ends is the same + // folder when typed back as it is, so it is compared as typed as well as trimmed. let kept = command == "folder.rename" && params["path"].as_str().is_some_and(|path| { - std::path::Path::new(path.trim_end_matches(['/', '\\'])).file_name().is_some_and(|f| f.to_string_lossy() == value.trim()) + std::path::Path::new(path.trim_end_matches(['/', '\\'])) + .file_name() + .is_some_and(|f| f.to_string_lossy() == value.as_str() || f.to_string_lossy() == value.trim()) }); if kept { return Ok(serde_json::Value::Null); diff --git a/crates/ui-egui/src/tests/text_prompt.rs b/crates/ui-egui/src/tests/text_prompt.rs index c089b2430..87328dedc 100644 --- a/crates/ui-egui/src/tests/text_prompt.rs +++ b/crates/ui-egui/src/tests/text_prompt.rs @@ -108,6 +108,20 @@ fn the_control_channel_keeps_a_refused_prompt_open_too() { Some(Dialog::TextPrompt { value, .. }) => assert_eq!(value, "Taken", "the typed text is kept"), other => panic!("the prompt stays open: {other:?}"), } + assert!(toast(&h).contains("already exists"), "the reason is on screen too, as with the button: {:?}", toast(&h)); + clean(&dir); +} + +#[test] +fn a_rename_that_keeps_a_name_with_spaces_at_its_ends_changes_nothing() { + let dir = shoot("rename-padded"); + std::fs::create_dir_all(dir.join(" Pad ")).unwrap(); + let mut h = app(); + // Rename Folder opens with the folder's own name typed, spaces and all + confirm(&mut h, prompt("folder.rename", " Pad ", json!({"path": dir.join(" Pad ").to_string_lossy()}), "name")); + assert_eq!(h.app.ui.dialog, None, "the prompt closes"); + assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h)); + assert!(dir.join(" Pad ").is_dir() && !dir.join("Pad").exists(), "the folder keeps its name"); clean(&dir); } From 16b984831b36c848fe594dd939c0405029a5de12 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 19:16:21 -0400 Subject: [PATCH 3/3] LR-BEHAV-DIALOGS: ui.dialog.confirm says where a new folder was made, as the dialog's button does --- crates/ui-egui/src/control.rs | 8 +++++--- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++---- crates/ui-egui/src/tests/text_prompt.rs | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index edf583b5f..48b1ac5db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,9 +414,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.confirm" => match app.ui.dialog.take() { Some(d) => { let r = crate::panels::dialogs::confirm_dialog(app, &d); - // a refused one-line prompt says why on screen, as with its button - if let (Err(e), crate::state::Dialog::TextPrompt { .. }) = (&r, &d) { - app.toast_error(ctx, e.clone()); + // a refused one-line prompt says why on screen, as with its button; a folder made says where + match (&r, &d) { + (Err(e), crate::state::Dialog::TextPrompt { .. }) => app.toast_error(ctx, e.clone()), + (Ok(made), crate::state::Dialog::TextPrompt { .. }) => crate::dialogs::toast_created_folder(app, ctx, &d, made), + _ => {} } // the import review, the rule editor and the one-line prompts stay open on an error, as with their buttons if (r.is_err() diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc4d06afe..f6a1bd896 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -437,6 +437,17 @@ fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut } } +/// Say where a folder made on disk is: the new folder holds no photos yet, so the Folders list may not show it. +/// Shared by the dialog's button and `ui.dialog.confirm`. +pub(crate) fn toast_created_folder(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &Dialog, made: &serde_json::Value) { + if let Dialog::TextPrompt { command, params, .. } = dlg + && command == "folder.create" + { + let (path, parent) = (made["path"].as_str().unwrap_or_default(), params["parent"].as_str().unwrap_or_default()); + app.toast(ctx, crate::panels::left::created_text(path, parent)); + } +} + /// The dialog's confirm was pressed: do it. Whether the dialog closes. fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool { let mut closed = false; @@ -474,10 +485,7 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> Err(e) if matches!(dlg, Dialog::TextPrompt { .. }) => app.toast_error(ctx, e), // a folder made on disk says so (the new folder holds no photos yet) Ok(r) if matches!(&dlg, Dialog::TextPrompt { command, .. } if command == "folder.create") => { - if let Dialog::TextPrompt { params, .. } = &dlg { - let (path, parent) = (r["path"].as_str().unwrap_or_default(), params["parent"].as_str().unwrap_or_default()); - app.toast(ctx, crate::panels::left::created_text(path, parent)); - } + toast_created_folder(app, ctx, dlg, &r); closed = true; } Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { diff --git a/crates/ui-egui/src/tests/text_prompt.rs b/crates/ui-egui/src/tests/text_prompt.rs index 87328dedc..81c383fd1 100644 --- a/crates/ui-egui/src/tests/text_prompt.rs +++ b/crates/ui-egui/src/tests/text_prompt.rs @@ -112,6 +112,20 @@ fn the_control_channel_keeps_a_refused_prompt_open_too() { clean(&dir); } +#[test] +fn the_control_channel_says_where_a_new_folder_was_made() { + let dir = shoot("control-ok"); + let mut h = app(); + h.app.ui.dialog = Some(prompt("folder.create", "Selects", json!({"parent": dir.to_string_lossy()}), "name")); + assert!(h.settle(SETTLE), "the prompt renders settled"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(dir.join("Selects").is_dir(), "made on disk"); + let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy()); + assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent)); + clean(&dir); +} + #[test] fn a_rename_that_keeps_a_name_with_spaces_at_its_ends_changes_nothing() { let dir = shoot("rename-padded");