diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 1ae372144..d7d128c52 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -1777,6 +1777,7 @@ "Show in Explorer": "Zobrazit v Průzkumníku", "Show in File Manager": "Zobrazit ve správci souborů", "Show in Finder": "Zobrazit ve Finderu", + "Show in Folders": "Zobrazit ve složkách", "Show less": "Zobrazit méně", "Show on Map": "Zobrazit na mapě", "Show overlay (O)": "Zobrazit překrytí (O)", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 000aea2d4..d4c889f03 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1428,6 +1428,7 @@ "Show in Explorer": "Im Explorer anzeigen", "Show in File Manager": "Im Dateimanager anzeigen", "Show in Finder": "Im Finder anzeigen", + "Show in Folders": "In Ordnern anzeigen", "Show on Map": "Auf Karte anzeigen", "Show overlay (O)": "Überlagerung anzeigen (O)", "Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77ba4c08e..e8f97b992 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1441,6 +1441,7 @@ "Show in Explorer": "Mostrar en el Explorador", "Show in File Manager": "Mostrar en el gestor de archivos", "Show in Finder": "Mostrar en el Finder", + "Show in Folders": "Mostrar en Carpetas", "Show on Map": "Mostrar en el mapa", "Show overlay (O)": "Mostrar superposición (O)", "Show ratings, flags and edit badges": "Mostrar valoraciones, marcas e indicadores de edición", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index fa57e373e..70109989d 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1364,6 +1364,7 @@ "Show in Explorer": "Afficher dans l'Explorateur", "Show in File Manager": "Afficher dans le gestionnaire de fichiers", "Show in Finder": "Afficher dans le Finder", + "Show in Folders": "Afficher dans les dossiers", "Show on Map": "Afficher sur la carte", "Show overlay (O)": "Afficher l'incrustation (O)", "Show ratings, flags and edit badges": "Afficher les notes, les drapeaux et les pastilles de retouche", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 455adb064..e7dc32d7f 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -1402,6 +1402,7 @@ "Show in Explorer": "Mostra in Esplora file", "Show in File Manager": "Mostra nel gestore file", "Show in Finder": "Mostra nel Finder", + "Show in Folders": "Mostra in Cartelle", "Show on Map": "Mostra sulla mappa", "Show overlay (O)": "Mostra sovrapposizione (O)", "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2dc80934f..16be44fa8 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1310,6 +1310,7 @@ "Show in Explorer": "エクスプローラーで表示", "Show in File Manager": "ファイルマネージャーで表示", "Show in Finder": "Finderで表示", + "Show in Folders": "フォルダで表示", "Show on Map": "地図で表示", "Show overlay (O)": "重ね表示(O)", "Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index f6530929d..066f2ecd2 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1927,6 +1927,7 @@ "Show in Explorer": "Explorer-ში ჩვენება", "Show in File Manager": "ფაილების მენეჯერში ჩვენება", "Show in Finder": "Finder-ში ჩვენება", + "Show in Folders": "საქაღალდეებში ჩვენება", "Show less": "ნაკლების ჩვენება", "Show on Map": "რუკაზე ჩვენება", "Show overlay (O)": "ზედდების ჩვენება (O)", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index f6576b1b5..b9dfd5125 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -1338,6 +1338,7 @@ "Show in Explorer": "Pokaż w Eksploratorze", "Show in File Manager": "Pokaż w menedżerze plików", "Show in Finder": "Pokaż w Finderze", + "Show in Folders": "Pokaż w folderach", "Show on Map": "Pokaż na mapie", "Show overlay (O)": "Pokaż nakładkę (O)", "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c9e9da05..1e9a7fa4f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1387,6 +1387,7 @@ "Show in Explorer": "Mostrar no Explorador de Arquivos", "Show in File Manager": "Mostrar no gerenciador de arquivos", "Show in Finder": "Mostrar no Finder", + "Show in Folders": "Mostrar em Pastas", "Show on Map": "Mostrar no mapa", "Show overlay (O)": "Mostrar sobreposição (O)", "Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5ead5101f..f2546d637 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1155,6 +1155,7 @@ "Show in Explorer": "Показать в «Проводнике»", "Show in File Manager": "Показать в файловом менеджере", "Show in Finder": "Показать в Finder", + "Show in Folders": "Показать в папках", "Show on Map": "Показать на карте", "Show overlay (O)": "Показать наложение (O)", "Show ratings, flags and edit badges": "Показывать оценки, флаги и значки правок", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index fbdef1c5f..1d6be4154 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1379,6 +1379,7 @@ "Show in Explorer": "Показати в Провіднику", "Show in File Manager": "Показати у файловому менеджері", "Show in Finder": "Показати у Finder", + "Show in Folders": "Показати в теках", "Show on Map": "Показати на карті", "Show overlay (O)": "Показати накладання (O)", "Show ratings, flags and edit badges": "Показати оцінки, прапорці й позначки змін", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0bf79c908..2950e4229 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1372,6 +1372,7 @@ "Show in Explorer": "在文件资源管理器中显示", "Show in File Manager": "在文件管理器中显示", "Show in Finder": "在 Finder 中显示", + "Show in Folders": "在文件夹中显示", "Show on Map": "在地图上显示", "Show overlay (O)": "显示叠加 (O)", "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a1723fc22..99fa3d99f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1372,6 +1372,7 @@ "Show in Explorer": "在檔案總管中顯示", "Show in File Manager": "在檔案管理員中顯示", "Show in Finder": "在 Finder 中顯示", + "Show in Folders": "在資料夾中顯示", "Show on Map": "在地圖上顯示", "Show overlay (O)": "顯示疊加(O)", "Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記", diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 364fac08f..e9f51a56f 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -334,6 +334,9 @@ pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { let panel = if id == "panel.presets" && app.ui.presets { Some(Panel::Presets) + } else if id == "app.showInFolders" { + // the sidebar it scrolls, also when it is a tab behind another panel + Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cb97b8c3..5316524f1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1759,6 +1759,10 @@ impl Caches { self.folder_tree = Some((cat.revision, t.clone())); t } + /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). + pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { + self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) + } /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 4d044c33e..1ac3e9926 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -214,6 +214,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.reload", "photo.readFocusPoints", "app.showInFinder", + "app.showInFolders", "dialog.rename", "dialog.captureTime", "photo.tagFromTracklog", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 988b08d88..7ad6ebe1a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -258,6 +258,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.showInFolders", "Show in Folders", None, "Photo"), ("app.openSamModelFolder", "Show in File Manager", None, ""), ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), ("dialog.labelNames", "Edit Color Label Names…", None, ""), @@ -2149,6 +2150,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), + "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), "app.openLogFolder" => open_log_folder(app), "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); @@ -2203,6 +2205,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .and_then(|id| app.session.catalog.photo(id)) .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } + "app.showInFolders" => show_in_folders_enabled(app, None), "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), @@ -2332,6 +2335,39 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { Ok(json!({"path": path})) } +/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a +/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed +/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. +fn photo_folder(app: &LightcraftApp, id: Option) -> Option { + let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; + let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) +} + +/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its +/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to +/// look in, the folder alone decides, and the command still checks. +pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { + let Some(dir) = photo_folder(app, id) else { return false }; + app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` +/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its +/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the +/// section and the folders above it open, and it scrolls there (`reveal_folder`). +fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { + let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; + let tree = app.caches.folder_tree(&app.session.catalog); + // the row's own spelling (forward slashes): what its click hands `library.source`, and its id + let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; + app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; + app.ui.left_panel = true; + app.ui.reveal_folder = Some(path.clone()); + Ok(json!({"path": path})) +} + /// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) /// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the /// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 3f18988bc..5dfb0c32d 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1404,6 +1404,11 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr(crate::menus::reveal_label()))).clicked() { let _ = app.run("app.showInFinder", json!({})); } + // the photo's folder in the sidebar's Folders section, as Lightroom's Go to Folder in Library + // (the photo right-clicked, which a multi-selection may leave other than the active one) + if ui.add_enabled(crate::menus::show_in_folders_enabled(app, Some(id)), egui::Button::new(crate::i18n::tr("Show in Folders"))).clicked() { + let _ = app.run("app.showInFolders", json!({"id": id.0})); + } if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3ccd3b77a..aa55d2e62 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -28,6 +28,10 @@ const ROW_HEIGHT: f32 = 29.0; /// this frame: set before the sections are drawn, cleared after. const SCROLL_TO_ROW: &str = "left-scroll-to-row"; +/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): +/// removed, they open again. +const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; + /// A row says how wide it needs to be. fn note_width(ui: &egui::Ui, w: f32) { ui.data_mut(|d| { @@ -720,6 +724,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // …and the sidebar scrolls to it ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase())); } + // the folder Show in Folders went to: Folders is shown and opened, and so are the + // folders above it, also when it was already the one shown and one was folded by hand… + if let Some(path) = app.ui.reveal_folder.take() { + app.ui.sidebar.reveal(SidebarSection::Folders); + ui.data_mut(|d| d.remove::>(egui::Id::new(LIBFOLDER_REVEALED))); + // …and the sidebar scrolls to it + ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase())); + } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); let shown: Vec = app.ui.sidebar.shown().collect(); @@ -1800,7 +1812,7 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); - let seen = egui::Id::new("libfolder-revealed"); + let seen = egui::Id::new(LIBFOLDER_REVEALED); let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { return; @@ -1819,6 +1831,16 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { open_above(ui, tree, &chosen); } +/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that +/// folder. +pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { + // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths + // too, so more than one may), and the tree is at most `MAX_DEPTH` deep + tree.iter() + .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) + .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) +} + fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { for n in nodes { let key = lightcraft_catalog::query::folder_key(&n.path); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1b90499a5..9e6335ce8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -870,6 +870,10 @@ pub struct UiState { /// A keyword just made from the sidebar: Keywords opens down to it, once. #[serde(skip)] pub reveal_keyword: Option, + /// A folder Photo ▸ Show in Folders went to (its Folders row's path): the sidebar's Folders + /// section opens down to it and scrolls to it, once. + #[serde(skip)] + pub reveal_folder: Option, /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). #[serde(skip)] pub dragging_album: Option, @@ -1422,6 +1426,7 @@ impl Default for UiState { dragging_photos: None, reveal_album: None, reveal_keyword: None, + reveal_folder: None, dragging_album: None, dragging_section: None, curve_channel: "parametric".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 612c1a4fb..dc4dec2d7 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1652,6 +1652,103 @@ fn the_chosen_folder_is_always_in_view() { assert!(!has(&h, "source:libfolder:/pics/trip/day1")); } +/// Make the photo imported from `path` the active one. +fn activate(h: &mut Headless, path: &str) { + let id = h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id; + h.app.run("library.select", json!({"ids": [id.0], "active": id.0})).unwrap(); + h.step(); +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library) shows the active photo's folder +/// as a click on its Folders row does: the sidebar, the section and the folders above the row open, +/// also when the folder was already shown and a row above it was folded by hand. +#[test] +fn show_in_folders_chooses_the_photos_folder_and_opens_the_rows_above_it() { + use crate::sidebar::SidebarSection; + let mut h = folders_app(&["/pics/trip/day1/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]); + activate(&mut h, "/pics/trip/day1/a.jpg"); + assert!(crate::menus::ui_enabled(&h.app, "app.showInFolders")); + click(&mut h, "sidebarSection:folders"); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert!(!h.app.ui.left_panel && !has(&h, "sidebarTitle"), "the sidebar is hidden"); + let r = h.request("ui.menu.invoke", json!({"id": "app.showInFolders"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::LibraryFolder); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/trip/day1")); + assert_eq!(h.app.session.visible().len(), 1, "the folder's photos fill the grid"); + assert_eq!(h.app.session.active(), h.app.session.visible().first().copied(), "the photo stays the active one"); + assert!(h.app.ui.left_panel && !h.app.ui.sidebar.is_collapsed(SidebarSection::Folders), "the sidebar and Folders opened"); + assert!(has(&h, "source:libfolder:/pics/trip/day1") && has(&h, "highlight:libfolder:/pics/trip/day1"), "its row is shown chosen"); + // folded by hand, then asked again: the rows above open again + click(&mut h, "libraryFolderToggle:/pics/trip"); + assert!(!has(&h, "source:libfolder:/pics/trip/day1")); + h.app.run("app.showInFolders", json!({})).unwrap(); + h.step(); + h.step(); + assert!(has(&h, "source:libfolder:/pics/trip/day1"), "opened again"); +} + +/// A folder far down the list is scrolled into view. +#[test] +fn show_in_folders_scrolls_the_folder_into_view() { + let paths: Vec = (0..60).map(|i| format!("/pics/f{i:02}/a.jpg")).collect(); + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_sized(&refs, [1400.0, 500.0]); + activate(&mut h, "/pics/f59/a.jpg"); + h.app.run("app.showInFolders", json!({})).unwrap(); + h.settle(SETTLE); + let row = widget(&h, "source:libfolder:/pics/f59"); + assert!(row.top() >= widget(&h, "sidebarTitle").bottom() && row.bottom() <= 500.0, "in view: {row:?}"); +} + +/// Without an active photo in a library folder there is nothing to show: no photo, a demo scene. +#[test] +fn show_in_folders_needs_a_photo_in_a_library_folder() { + let mut h = folders_app(&["/pics/trip/a.jpg"]); + h.app.run("library.select", json!({"ids": []})).unwrap(); + assert_eq!(h.app.session.active(), None); + assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders")); + let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "app.showInFolders").unwrap(); + assert!(!entry.enabled && entry.menu == ["Photo"], "listed in Photo, disabled"); + assert!(h.app.run("app.showInFolders", json!({})).is_err()); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All, "nothing changed"); + let mut demo = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let scene = demo.session.visible().first().copied().unwrap(); + demo.run("library.select", json!({"ids": [scene.0]})).unwrap(); + assert!(!crate::menus::ui_enabled(&demo, "app.showInFolders"), "a demo scene has no folder"); +} + +/// The grid's menu reveals the photo right-clicked, not the active one a multi-selection keeps. +#[test] +fn show_in_folders_reveals_the_photo_asked_for_and_follows_to_its_folder() { + let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/c.jpg"]); + let id_of = |h: &Headless, path: &str| { + h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id + }; + let (a, c) = (id_of(&h, "/pics/trip/a.jpg"), id_of(&h, "/pics/home/c.jpg")); + h.app.run("library.select", json!({"ids": [a.0, c.0], "active": a.0})).unwrap(); + h.step(); + let r = h.app.run("app.showInFolders", json!({"id": c.0})).unwrap(); + assert_eq!(r["path"], "/pics/home"); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home")); + assert_eq!(h.app.session.active(), Some(c), "the view of that folder shows the photo asked for"); + assert!(crate::menus::show_in_folders_enabled(&h.app, Some(c))); +} + +/// A folder deeper than the tree goes has no row: the entry is disabled rather than failing. +#[test] +fn show_in_folders_is_disabled_for_a_folder_below_the_trees_depth() { + let deep = format!("/pics{}/a.jpg", "/d".repeat(lightcraft_catalog::folders::MAX_DEPTH + 4)); + let mut h = folders_app(&[deep.as_str()]); + activate(&mut h, &deep); + assert!(!h.app.caches.folder_tree(&h.app.session.catalog).is_empty()); + assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders")); + assert!(h.app.run("app.showInFolders", json!({})).is_err()); +} + #[test] fn a_renamed_folder_keeps_its_place_in_the_tree() { /// Removes the folder however the test ends. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 502acdc58..bf1a66129 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -51,10 +51,10 @@ cited here still exists. Formerly docs/parity.md. | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | -| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | +| Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 24 | 22 | 8 | — | 24/26 (92%) | -| **Total** | 419 | 62 | 58 | 35 | 194/201 (97%) | 148/161 (92%) | +| **Total** | 420 | 62 | 58 | 35 | 194/201 (97%) | 148/161 (92%) | Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 56.0% of 176. @@ -516,6 +516,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | | MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | +| MENU-PHOTO-SHOWFOLDERS | Show in Folders | P2 | ✅ | `cmd:app.showInFolders`, `crates/ui-egui/src/panels/left.rs` (`folder_row_of`), tests `show_in_folders_*` in `tests_panels.rs` | Photo ▸ Show in Folders and the photo right-click menu: selects the active photo's folder in the sidebar's Folders section, opens the rows above it and scrolls to it; opens the Library sidebar if it is behind another tab; disabled for demo scenes and photos outside a library folder | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | | MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | | MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step |