From 6bda431c4b9d58dab370d07960eec2f72720b454 Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 19:30:23 -0600 Subject: [PATCH] LR-LIB-DELETE: restore from Recently Deleted; re-adding a deleted photo shows where it is Photos in Recently Deleted could not be restored from the app: photo.restore and photo.deletePermanently existed only as engine commands, and adding the same file again just said "1 duplicate skipped" (the duplicate check counts deleted photos), with the side panel that lists Recently Deleted collapsed by default. - Restore and Delete Permanently in the Photo menu and the photo context menu, in place of Delete, when the selection is in Recently Deleted. - The filmstrip gets the photo context menu (it had none). - An import that only finds files the library already has selects those photos: in Recently Deleted (side panel opened, how to restore or delete permanently and import afresh) or in All Photos. Mixed imports note how many are in Recently Deleted. The message stays 6 s. - Import review badges say "In Recently Deleted"; badges are translated. - Japanese strings, parity rows, what's new; headless regression test. --- crates/engine/src/cmd/library.rs | 4 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/src/headless.rs | 81 ++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 53 ++++++++++++++++- crates/ui-egui/src/lib.rs | 7 ++- crates/ui-egui/src/menubar.rs | 18 +++++- crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/grid.rs | 9 ++- docs/parity.md | 12 ++-- docs/whats-new.md | 5 ++ 11 files changed, 183 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0..cba1c338 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -453,11 +453,11 @@ pub fn specs() -> Vec { s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); Ok(v) }), - cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { + cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, deleted: false }))), - cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| { + cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let t = s.targets(p); let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::>(); s.commit("Delete Permanently", Op::Batch { ops })?; diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f8..354205c8 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -100,5 +100,8 @@ "… and {more} more": "…ほか{more}件", "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚", + " ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)", + "Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5..2a6f18a0 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "Quit": "終了", "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", "Back Up Library…": "ライブラリをバックアップ…", - "Restore Library from Backup…": "バックアップからライブラリを復元…" + "Restore Library from Backup…": "バックアップからライブラリを復元…", + "In Recently Deleted": "最近削除した写真内" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a78fe661..a6b8fc43 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1236,6 +1236,87 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo + /// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once + /// restored a re-add selects it in All Photos. + #[test] + fn readding_a_deleted_photo_shows_it_in_recently_deleted() { + let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let file = dir.join("flower.png"); + std::fs::write(&file, png).unwrap(); + let paths = vec![file.to_string_lossy().to_string()]; + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + // frames until the import has finished, so its (timed) toast is read before it expires + let finish_import = |h: &mut Headless| { + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "import finished"); + }; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + h.settle(SETTLE); + let id = h.app.session.selection.active.expect("imported photo selected"); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted); + let photo_items = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; + items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() + }; + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); + + h.app.ui.left_panel = false; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); + assert_eq!(h.app.session.selection.ids, vec![id]); + assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened"); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}"); + let items = photo_items(&h.app); + assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); + assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); + // a right-click on its filmstrip thumbnail opens the photo menu + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); + + let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); + crate::import::start_paths(&mut h.app, paths).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id]); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("All Photos"), "{toast}"); + + // deleted permanently, the file imports afresh as a new photo + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).is_none()); + crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + let fresh = h.app.session.selection.active.expect("re-imported photo selected"); + assert_ne!(fresh, id); + assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); + let _ = std::fs::remove_dir_all(&dir); + } + /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across /// the import's batches. #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 7e520bb3..3ac7c9eb 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -105,6 +105,8 @@ pub struct ImportTask { params: Value, pub imported: usize, pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, pub failed: usize, /// Move: originals moved, and sources left in place (reported by the engine with a reason). pub moved: usize, @@ -563,6 +565,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc let len = |k: &str| v[k].as_array().map_or(0, Vec::len); task.imported += len("imported"); task.duplicates += len("duplicates"); + task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); task.failed += len("failed"); task.moved += len("moved"); task.kept += len("kept"); @@ -601,6 +604,11 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let _ = app.run("library.select", json!({"ids": [f]})); } let plural = |n: usize| if n == 1 { "" } else { "s" }; + // nothing new, only files the library already has: show where they are (Recently Deleted is + // easy to miss, and the side panel that lists it starts collapsed) + if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) { + return; + } let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else { @@ -614,6 +622,10 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } if task.duplicates > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } } if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); @@ -621,6 +633,43 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { app.toast(ctx, msg); } +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +} + /// The progress window while an import runs, with Cancel (files already copied or added stay; /// nothing new is started). pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { @@ -949,14 +998,16 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); let badge = match (&c.duplicate, &c.error) { + (Some(_), _) if in_trash => Some("In Recently Deleted"), (Some(r), _) if r == "path" => Some("In library"), (Some(_), _) => Some("Duplicate"), (None, Some(_)) => Some("Unreadable"), _ => None, }; if let Some(b) = badge { - let g = p.layout_no_wrap(b.to_string(), t.semibold(10.0), Color32::WHITE); + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); let br = Rect::from_min_size(pos2(img.right() - g.size().x - 12.0, img.top() + 6.0), g.size() + vec2(8.0, 4.0)); p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b..20e54730 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,8 +344,13 @@ impl LightcraftApp { } pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { + self.toast_for(ctx, text, 1.4); + } + + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). + pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 1.4)); + self.ui.toast = Some((text.into(), t + secs)); } fn drain_control(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e..30d8f6f6 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -192,6 +192,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.tagFromTracklog", "---", "photo.delete", + "photo.restore", + "photo.deletePermanently", ], ), ( @@ -252,11 +254,20 @@ const HIDDEN: &[&str] = &[ "preset.create", ]; -/// Items only some hosts have are left out of the others' menus (the web build's library backup). +/// The selection includes a photo in Recently Deleted. +pub(crate) fn selection_deleted(app: &LightcraftApp) -> bool { + let s = &app.session; + s.selection.ids.iter().copied().chain(s.selection.active).any(|id| s.catalog.photo(id).is_some_and(|p| p.deleted)) +} + +/// Items only some hosts have are left out of the others' menus (the web build's library backup); +/// Restore and Delete Permanently replace Delete for photos in Recently Deleted. fn host_supports(app: &LightcraftApp, id: &str) -> bool { match id { "file.backupLibrary" => app.services.backup_library.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "photo.restore" | "photo.deletePermanently" => selection_deleted(app), + "photo.delete" => !selection_deleted(app), _ => true, } } @@ -828,8 +839,9 @@ mod tests { let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(titles, MENUS); let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); } for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75c..3448fbf5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1300,6 +1300,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }; let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode})); } + // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); } }); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe..edc4662f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -905,7 +905,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); - if ui.button(crate::i18n::tr("Delete Photo")).clicked() { + if crate::menubar::selection_deleted(app) { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("photo.restore", json!({})); + } + if ui.button(crate::i18n::tr("Delete Permanently")).clicked() { + let _ = app.run("photo.deletePermanently", json!({})); + } + } else if ui.button(crate::i18n::tr("Delete Photo")).clicked() { let _ = app.run("photo.delete", json!({})); } } diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..04861b1b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 387 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.6% of 158. ## Top gaps @@ -148,7 +148,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -169,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session | @@ -600,7 +600,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub | | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | -| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | +| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045dead..92bad949 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,11 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the + Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no + longer just says "duplicate skipped": it opens the side panel on Recently Deleted with the photo selected and says + how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits, + Cmd+Shift+R, keeps the photo and clears its edits.) - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through.