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 700c03f6..597a5cb9 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1364,6 +1364,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 f1d6486c..56ea43fb 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 c25638f9..19ac1fd3 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.