collective

Browse / github.com/storytold/lightcraft / patch

MENU-CTX-GRID: the photo menu's rows, built from the menu bar's model

MENU-CTX-GRID: the photo menu's rows, built from the menu bar's model Off `main`. Part 1 of 2: the model a new photo right-click menu is built from, with no caller yet; part 2, #3026 (stacked on this one), draws it in the grid, the filmstrip, the loupe and Compare / Survey in place of today's menu. ## Problem Right-clicking a photo opens a hand-built list in `crates/ui-egui/src/panels/grid.rs` that grew one feature at a time. One group holds 12 unrelated actions (rating, albums, file moves, versions, stacks). The rows show no keyboard shortcuts and no check mark on the photo's current rating, flag or label, and their labels ignore the selection ("Delete Photo"

Status
open
Score
0.00
Adopters
0 other operators
Author
nledford (imported from pull request #3014, open)
Base
bb08a697a41b2561a54f1b740245119503b24758
Size
+1225 −53 in 40 files
Review
pending
Shared
2026-10-28
Id
222ebf0e74da3b1a35b5d878

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/cs.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi-formats.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id-formats.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv-formats.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi-formats.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/lib.rs crates/ui-egui/src/menubar.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/photo_menu.rs crates/ui-egui/src/photo_menu_tests.rs crates/ui-egui/src/tests_panels.rs

diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json--- a/crates/ui-egui/locales/cs-formats.json+++ b/crates/ui-egui/locales/cs-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}",   "Added {n} photo{}": "Přidáno fotografií: {n}{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",-  "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})",+  "Copied {n} file paths": "Zkopírované cesty k souborům: {n}" }diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json--- a/crates/ui-egui/locales/cs.json+++ b/crates/ui-egui/locales/cs.json@@ -2408,5 +2408,20 @@   "Import dropped files without review": "Importovat přetažené soubory bez kontroly",   "An import is running": "Import už běží",   "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",-  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"+  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky",+  "Open in Compare": "Otevřít v porovnání",+  "Open in Survey": "Otevřít v přehledu",+  "Edit Settings": "Nastavení úprav",+  "Rotate & Flip": "Otočit a převrátit",+  "Copy File Path": "Kopírovat cestu k souboru",+  "Copied the file path": "Cesta k souboru zkopírována",+  "The selected photos have no file on disk": "Vybrané fotografie nemají soubor na disku",+  "Select 2 or more photos": "Vyberte 2 nebo více fotografií",+  "Copy edit settings first": "Nejprve zkopírujte nastavení úprav",+  "Copy metadata first": "Nejprve zkopírujte metadata",+  "This version of LightCraft can't open the file manager": "Tato verze LightCraftu neumí otevřít správce souborů",+  "This photo has no file on disk": "Tato fotografie nemá soubor na disku",+  "This photo is not in a folder of the library": "Tato fotografie není ve složce knihovny",+  "Opening other programs needs the desktop app": "Otevírání jiných programů vyžaduje desktopovou aplikaci",+  "Only a photo file LightCraft reads can be opened this way": "Takto lze otevřít jen soubor fotografie, který LightCraft umí načíst" }diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json--- a/crates/ui-egui/locales/de-formats.json+++ b/crates/ui-egui/locales/de-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}",   "Added {n} photo{}": "{n} Foto{} hinzugefügt",   "Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",-  "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"+  "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})",+  "Copied {n} file paths": "{n} Dateipfade kopiert" }diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json--- a/crates/ui-egui/locales/de.json+++ b/crates/ui-egui/locales/de.json@@ -3440,5 +3440,20 @@   "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren",   "An import is running": "Ein Import läuft",   "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",-  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"+  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst",+  "Open in Compare": "In Vergleich öffnen",+  "Open in Survey": "In Übersicht öffnen",+  "Edit Settings": "Bearbeitungseinstellungen",+  "Rotate & Flip": "Drehen und spiegeln",+  "Copy File Path": "Dateipfad kopieren",+  "Copied the file path": "Dateipfad kopiert",+  "The selected photos have no file on disk": "Die ausgewählten Fotos haben keine Datei auf dem Datenträger",+  "Select 2 or more photos": "Wähle 2 oder mehr Fotos aus",+  "Copy edit settings first": "Kopiere zuerst Bearbeitungseinstellungen",+  "Copy metadata first": "Kopiere zuerst Metadaten",+  "This version of LightCraft can't open the file manager": "Diese Version von LightCraft kann den Dateimanager nicht öffnen",+  "This photo has no file on disk": "Dieses Foto hat keine Datei auf dem Datenträger",+  "This photo is not in a folder of the library": "Dieses Foto liegt in keinem Ordner der Bibliothek",+  "Opening other programs needs the desktop app": "Andere Programme lassen sich nur in der Desktop-App öffnen",+  "Only a photo file LightCraft reads can be opened this way": "So lässt sich nur eine Fotodatei öffnen, die LightCraft lesen kann" }diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json--- a/crates/ui-egui/locales/es-formats.json+++ b/crates/ui-egui/locales/es-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "No se pudo leer {name}: {e}",   "Added {n} photo{}": "{n} foto{0} añadida{0}",   "Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",-  "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"+  "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})",+  "Copied {n} file paths": "{n} rutas de archivo copiadas" }diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json--- a/crates/ui-egui/locales/es.json+++ b/crates/ui-egui/locales/es.json@@ -2358,5 +2358,20 @@   "Import dropped files without review": "Importar los archivos soltados sin revisión",   "An import is running": "Hay una importación en curso",   "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",-  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"+  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos",+  "Open in Compare": "Abrir en Comparar",+  "Open in Survey": "Abrir en Encuesta",+  "Edit Settings": "Ajustes de edición",+  "Rotate & Flip": "Rotar y voltear",+  "Copy File Path": "Copiar ruta del archivo",+  "Copied the file path": "Ruta del archivo copiada",+  "The selected photos have no file on disk": "Las fotos seleccionadas no tienen archivo en el disco",+  "Select 2 or more photos": "Selecciona 2 o más fotos",+  "Copy edit settings first": "Copia primero los ajustes de edición",+  "Copy metadata first": "Copia primero los metadatos",+  "This version of LightCraft can't open the file manager": "Esta versión de LightCraft no puede abrir el gestor de archivos",+  "This photo has no file on disk": "Esta foto no tiene archivo en el disco",+  "This photo is not in a folder of the library": "Esta foto no está en una carpeta de la biblioteca",+  "Opening other programs needs the desktop app": "Abrir otros programas requiere la aplicación de escritorio",+  "Only a photo file LightCraft reads can be opened this way": "Solo se puede abrir así un archivo de foto que LightCraft lea" }diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json--- a/crates/ui-egui/locales/fi-formats.json+++ b/crates/ui-egui/locales/fi-formats.json@@ -305,5 +305,6 @@   "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",   "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",-  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})",+  "Copied {n} file paths": "Kopioituja tiedostopolkuja: {n}" }diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json--- a/crates/ui-egui/locales/fi.json+++ b/crates/ui-egui/locales/fi.json@@ -2327,5 +2327,20 @@   "Drop to open the face model": "Pudota avataksesi kasvomallin",   "Dropped Files": "Pudotetut tiedostot",   "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",-  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"+  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",+  "Open in Compare": "Avaa Vertaa-näkymässä",+  "Open in Survey": "Avaa Vertailu-näkymässä",+  "Edit Settings": "Muokkausasetukset",+  "Rotate & Flip": "Kierrä ja käännä",+  "Copy File Path": "Kopioi tiedostopolku",+  "Copied the file path": "Tiedostopolku kopioitu",+  "The selected photos have no file on disk": "Valituilla kuvilla ei ole tiedostoa levyllä",+  "Select 2 or more photos": "Valitse vähintään 2 kuvaa",+  "Copy edit settings first": "Kopioi ensin muokkausasetukset",+  "Copy metadata first": "Kopioi ensin metatiedot",+  "This version of LightCraft can't open the file manager": "Tämä LightCraftin versio ei voi avata tiedostonhallintaa",+  "This photo has no file on disk": "Tällä kuvalla ei ole tiedostoa levyllä",+  "This photo is not in a folder of the library": "Tämä kuva ei ole kirjaston kansiossa",+  "Opening other programs needs the desktop app": "Muiden ohjelmien avaaminen vaatii työpöytäsovelluksen",+  "Only a photo file LightCraft reads can be opened this way": "Näin voi avata vain kuvatiedoston, jonka LightCraft lukee" }diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json--- a/crates/ui-egui/locales/fr-formats.json+++ b/crates/ui-egui/locales/fr-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Impossible de lire {name} : {e}",   "Added {n} photo{}": "{n} photo{} ajoutée(s)",   "Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",-  "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"+  "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})",+  "Copied {n} file paths": "{n} chemins de fichier copiés" }diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json--- a/crates/ui-egui/locales/fr.json+++ b/crates/ui-egui/locales/fr.json@@ -2390,5 +2390,20 @@   "Checking recent libraries": "Vérification des bibliothèques récentes",   "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte",   "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",-  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"+  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :",+  "Open in Compare": "Ouvrir dans Comparer",+  "Open in Survey": "Ouvrir dans Vue multiple",+  "Edit Settings": "Réglages de retouche",+  "Rotate & Flip": "Pivoter et retourner",+  "Copy File Path": "Copier le chemin du fichier",+  "Copied the file path": "Chemin du fichier copié",+  "The selected photos have no file on disk": "Les photos sélectionnées n’ont pas de fichier sur le disque",+  "Select 2 or more photos": "Sélectionnez 2 photos ou plus",+  "Copy edit settings first": "Copiez d’abord des réglages de retouche",+  "Copy metadata first": "Copiez d’abord des métadonnées",+  "This version of LightCraft can't open the file manager": "Cette version de LightCraft ne peut pas ouvrir le gestionnaire de fichiers",+  "This photo has no file on disk": "Cette photo n’a pas de fichier sur le disque",+  "This photo is not in a folder of the library": "Cette photo n’est dans aucun dossier de la bibliothèque",+  "Opening other programs needs the desktop app": "Ouvrir d’autres programmes nécessite l’application de bureau",+  "Only a photo file LightCraft reads can be opened this way": "Seul un fichier photo que LightCraft sait lire peut être ouvert ainsi" }diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json--- a/crates/ui-egui/locales/id-formats.json+++ b/crates/ui-egui/locales/id-formats.json@@ -305,5 +305,6 @@   "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",   "Inside: {names}": "Di dalam: {names}",   "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",-  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})",+  "Copied {n} file paths": "{n} jalur file disalin" }diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json--- a/crates/ui-egui/locales/id.json+++ b/crates/ui-egui/locales/id.json@@ -2339,5 +2339,20 @@   "Drop to open the face model": "Jatuhkan untuk membuka model wajah",   "Dropped Files": "File yang dijatuhkan",   "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",-  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"+  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",+  "Open in Compare": "Buka di Bandingkan",+  "Open in Survey": "Buka di Survei",+  "Edit Settings": "Pengaturan edit",+  "Rotate & Flip": "Putar dan balik",+  "Copy File Path": "Salin jalur file",+  "Copied the file path": "Jalur file disalin",+  "The selected photos have no file on disk": "Foto yang dipilih tidak memiliki file di disk",+  "Select 2 or more photos": "Pilih 2 foto atau lebih",+  "Copy edit settings first": "Salin pengaturan edit terlebih dahulu",+  "Copy metadata first": "Salin metadata terlebih dahulu",+  "This version of LightCraft can't open the file manager": "Versi LightCraft ini tidak dapat membuka pengelola file",+  "This photo has no file on disk": "Foto ini tidak memiliki file di disk",+  "This photo is not in a folder of the library": "Foto ini tidak berada di folder pustaka",+  "Opening other programs needs the desktop app": "Membuka program lain memerlukan aplikasi desktop",+  "Only a photo file LightCraft reads can be opened this way": "Hanya file foto yang dibaca LightCraft yang dapat dibuka dengan cara ini" }diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json--- a/crates/ui-egui/locales/it-formats.json+++ b/crates/ui-egui/locales/it-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Impossibile leggere {name}: {e}",   "Added {n} photo{}": "Foto aggiunte: {n}{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",-  "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})",+  "Copied {n} file paths": "Percorsi dei file copiati: {n}" }diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json--- a/crates/ui-egui/locales/it.json+++ b/crates/ui-egui/locales/it.json@@ -2361,5 +2361,20 @@   "Import dropped files without review": "Importa i file rilasciati senza revisione",   "An import is running": "Un'importazione è in corso",   "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",-  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"+  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto",+  "Open in Compare": "Apri in Confronta",+  "Open in Survey": "Apri in Panoramica",+  "Edit Settings": "Impostazioni di modifica",+  "Rotate & Flip": "Ruota e capovolgi",+  "Copy File Path": "Copia percorso del file",+  "Copied the file path": "Percorso del file copiato",+  "The selected photos have no file on disk": "Le foto selezionate non hanno un file sul disco",+  "Select 2 or more photos": "Seleziona 2 o più foto",+  "Copy edit settings first": "Copia prima le impostazioni di modifica",+  "Copy metadata first": "Copia prima i metadati",+  "This version of LightCraft can't open the file manager": "Questa versione di LightCraft non può aprire il gestore file",+  "This photo has no file on disk": "Questa foto non ha un file sul disco",+  "This photo is not in a folder of the library": "Questa foto non è in una cartella della libreria",+  "Opening other programs needs the desktop app": "Aprire altri programmi richiede l’app desktop",+  "Only a photo file LightCraft reads can be opened this way": "Così si può aprire solo un file foto che LightCraft legge" }diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json--- a/crates/ui-egui/locales/ja-formats.json+++ b/crates/ui-egui/locales/ja-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "{name} を読み取れませんでした: {e}",   "Added {n} photo{}": "{n}枚を追加しました{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?",-  "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}",+  "Copied {n} file paths": "{n} 件のファイルパスをコピーしました" }diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json--- a/crates/ui-egui/locales/ja.json+++ b/crates/ui-egui/locales/ja.json@@ -2356,5 +2356,20 @@   "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む",   "An import is running": "読み込み中です",   "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",-  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"+  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください",+  "Open in Compare": "比較表示で開く",+  "Open in Survey": "一覧比較で開く",+  "Edit Settings": "編集設定",+  "Rotate & Flip": "回転と反転",+  "Copy File Path": "ファイルパスをコピー",+  "Copied the file path": "ファイルパスをコピーしました",+  "The selected photos have no file on disk": "選択した写真にはディスク上のファイルがありません",+  "Select 2 or more photos": "2 枚以上の写真を選択してください",+  "Copy edit settings first": "先に編集設定をコピーしてください",+  "Copy metadata first": "先にメタデータをコピーしてください",+  "This version of LightCraft can't open the file manager": "このバージョンの LightCraft はファイルマネージャーを開けません",+  "This photo has no file on disk": "この写真にはディスク上のファイルがありません",+  "This photo is not in a folder of the library": "この写真はライブラリのフォルダにありません",+  "Opening other programs needs the desktop app": "ほかのプログラムを開くにはデスクトップ版が必要です",+  "Only a photo file LightCraft reads can be opened this way": "この方法で開けるのは LightCraft が読み込める写真ファイルだけです" }diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json--- a/crates/ui-egui/locales/ka-formats.json+++ b/crates/ui-egui/locales/ka-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}",   "Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",-  "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}",+  "Copied {n} file paths": "დაკოპირებული ფაილის მისამართები: {n}" }diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json--- a/crates/ui-egui/locales/ka.json+++ b/crates/ui-egui/locales/ka.json@@ -2734,5 +2734,20 @@   "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე",   "An import is running": "იმპორტი მიმდინარეობს",   "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",-  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"+  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი",+  "Open in Compare": "შედარებაში გახსნა",+  "Open in Survey": "მიმოხილვაში გახსნა",+  "Edit Settings": "რედაქტირების პარამეტრები",+  "Rotate & Flip": "შემობრუნება და არეკვლა",+  "Copy File Path": "ფაილის მისამართის კოპირება",+  "Copied the file path": "ფაილის მისამართი დაკოპირდა",+  "The selected photos have no file on disk": "არჩეულ ფოტოებს დისკზე ფაილი არ აქვთ",+  "Select 2 or more photos": "აირჩიეთ 2 ან მეტი ფოტო",+  "Copy edit settings first": "ჯერ დააკოპირეთ რედაქტირების პარამეტრები",+  "Copy metadata first": "ჯერ დააკოპირეთ მეტამონაცემები",+  "This version of LightCraft can't open the file manager": "LightCraft-ის ამ ვერსიას ფაილების მენეჯერის გახსნა არ შეუძლია",+  "This photo has no file on disk": "ამ ფოტოს დისკზე ფაილი არ აქვს",+  "This photo is not in a folder of the library": "ეს ფოტო ბიბლიოთეკის საქაღალდეში არ არის",+  "Opening other programs needs the desktop app": "სხვა პროგრამების გასახსნელად საჭიროა სამუშაო მაგიდის აპი",+  "Only a photo file LightCraft reads can be opened this way": "ასე მხოლოდ ისეთი ფოტოს ფაილი იხსნება, რომელსაც LightCraft კითხულობს" }diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json--- a/crates/ui-egui/locales/pl-formats.json+++ b/crates/ui-egui/locales/pl-formats.json@@ -305,5 +305,6 @@   "Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}",   "Inside: {names}": "Wewnątrz: {names}",   "Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",-  "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}",+  "Copied {n} file paths": "Skopiowane ścieżki plików: {n}" }diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json--- a/crates/ui-egui/locales/pl.json+++ b/crates/ui-egui/locales/pl.json@@ -2283,5 +2283,20 @@   "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu",   "An import is running": "Import jest w toku",   "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",-  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"+  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia",+  "Open in Compare": "Otwórz w widoku porównania",+  "Open in Survey": "Otwórz w widoku przeglądu",+  "Edit Settings": "Ustawienia edycji",+  "Rotate & Flip": "Obróć i odbij",+  "Copy File Path": "Kopiuj ścieżkę pliku",+  "Copied the file path": "Skopiowano ścieżkę pliku",+  "The selected photos have no file on disk": "Wybrane zdjęcia nie mają pliku na dysku",+  "Select 2 or more photos": "Wybierz co najmniej 2 zdjęcia",+  "Copy edit settings first": "Najpierw skopiuj ustawienia edycji",+  "Copy metadata first": "Najpierw skopiuj metadane",+  "This version of LightCraft can't open the file manager": "Ta wersja LightCraft nie może otworzyć menedżera plików",+  "This photo has no file on disk": "To zdjęcie nie ma pliku na dysku",+  "This photo is not in a folder of the library": "To zdjęcie nie jest w folderze biblioteki",+  "Opening other programs needs the desktop app": "Otwieranie innych programów wymaga aplikacji na komputer",+  "Only a photo file LightCraft reads can be opened this way": "W ten sposób można otworzyć tylko plik zdjęcia, który LightCraft odczytuje" }diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json--- a/crates/ui-egui/locales/pt-br-formats.json+++ b/crates/ui-egui/locales/pt-br-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Não foi possível ler {name}: {e}",   "Added {n} photo{}": "{n} foto{0} adicionada{0}",   "Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",-  "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"+  "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})",+  "Copied {n} file paths": "{n} caminhos de arquivo copiados" }diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json--- a/crates/ui-egui/locales/pt-br.json+++ b/crates/ui-egui/locales/pt-br.json@@ -2202,5 +2202,20 @@   "Import dropped files without review": "Importar arquivos soltos sem revisão",   "An import is running": "Uma importação está em andamento",   "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar",-  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos"+  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos",+  "Open in Compare": "Abrir em Comparar",+  "Open in Survey": "Abrir em Inspeção",+  "Edit Settings": "Ajustes de edição",+  "Rotate & Flip": "Girar e espelhar",+  "Copy File Path": "Copiar caminho do arquivo",+  "Copied the file path": "Caminho do arquivo copiado",+  "The selected photos have no file on disk": "As fotos selecionadas não têm arquivo no disco",+  "Select 2 or more photos": "Selecione 2 ou mais fotos",+  "Copy edit settings first": "Copie primeiro os ajustes de edição",+  "Copy metadata first": "Copie primeiro os metadados",+  "This version of LightCraft can't open the file manager": "Esta versão do LightCraft não pode abrir o gerenciador de arquivos",+  "This photo has no file on disk": "Esta foto não tem arquivo no disco",+  "This photo is not in a folder of the library": "Esta foto não está em uma pasta da biblioteca",+  "Opening other programs needs the desktop app": "Abrir outros programas requer o app para computador",+  "Only a photo file LightCraft reads can be opened this way": "Só é possível abrir assim um arquivo de foto que o LightCraft lê" }diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json--- a/crates/ui-egui/locales/ru-formats.json+++ b/crates/ui-egui/locales/ru-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Не удалось прочитать {name}: {e}",   "Added {n} photo{}": "Добавлено фото: {n}{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?",-  "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}",+  "Copied {n} file paths": "Скопировано путей к файлам: {n}" }diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json--- a/crates/ui-egui/locales/ru.json+++ b/crates/ui-egui/locales/ru.json@@ -2349,5 +2349,20 @@   "Import dropped files without review": "Импортировать перетащенные файлы без проверки",   "An import is running": "Импорт уже выполняется",   "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится",-  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото"+  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото",+  "Open in Compare": "Открыть в режиме «Сравнение»",+  "Open in Survey": "Открыть в режиме «Обзор»",+  "Edit Settings": "Правки",+  "Rotate & Flip": "Поворот и отражение",+  "Copy File Path": "Копировать путь к файлу",+  "Copied the file path": "Путь к файлу скопирован",+  "The selected photos have no file on disk": "У выбранных фото нет файла на диске",+  "Select 2 or more photos": "Выберите 2 или больше фото",+  "Copy edit settings first": "Сначала скопируйте правки",+  "Copy metadata first": "Сначала скопируйте метаданные",+  "This version of LightCraft can't open the file manager": "Эта версия LightCraft не может открыть файловый менеджер",+  "This photo has no file on disk": "У этого фото нет файла на диске",+  "This photo is not in a folder of the library": "Это фото не находится в папке библиотеки",+  "Opening other programs needs the desktop app": "Открывать другие программы можно только в настольном приложении",+  "Only a photo file LightCraft reads can be opened this way": "Так можно открыть только файл фото, который читает LightCraft" }diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json--- a/crates/ui-egui/locales/sv-formats.json+++ b/crates/ui-egui/locales/sv-formats.json@@ -305,5 +305,6 @@   "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",   "Inside: {names}": "Inuti: {names}",   "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",-  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})",+  "Copied {n} file paths": "Kopierade filsökvägar: {n}" }diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json--- a/crates/ui-egui/locales/sv.json+++ b/crates/ui-egui/locales/sv.json@@ -2429,5 +2429,20 @@   "Drop to open the face model": "Släpp för att öppna ansiktsmodellen",   "Dropped Files": "Släppta filer",   "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",-  "Import dropped files without review": "Importera släppta filer utan granskning"+  "Import dropped files without review": "Importera släppta filer utan granskning",+  "Open in Compare": "Öppna i Jämför",+  "Open in Survey": "Öppna i Översikt",+  "Edit Settings": "Redigeringsinställningar",+  "Rotate & Flip": "Rotera och vänd",+  "Copy File Path": "Kopiera filsökväg",+  "Copied the file path": "Filsökvägen har kopierats",+  "The selected photos have no file on disk": "De valda fotona har ingen fil på disken",+  "Select 2 or more photos": "Välj 2 eller fler foton",+  "Copy edit settings first": "Kopiera redigeringsinställningar först",+  "Copy metadata first": "Kopiera metadata först",+  "This version of LightCraft can't open the file manager": "Den här versionen av LightCraft kan inte öppna filhanteraren",+  "This photo has no file on disk": "Det här fotot har ingen fil på disken",+  "This photo is not in a folder of the library": "Det här fotot ligger inte i en mapp i biblioteket",+  "Opening other programs needs the desktop app": "Att öppna andra program kräver skrivbordsappen",+  "Only a photo file LightCraft reads can be opened this way": "Bara en fotofil som LightCraft läser kan öppnas så här" }diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json--- a/crates/ui-egui/locales/uk-formats.json+++ b/crates/ui-egui/locales/uk-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}",   "Added {n} photo{}": "Додано фото: {n}{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?",-  "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}",+  "Copied {n} file paths": "Скопійовано шляхів до файлів: {n}" }diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json--- a/crates/ui-egui/locales/uk.json+++ b/crates/ui-egui/locales/uk.json@@ -2354,5 +2354,20 @@   "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду",   "An import is running": "Імпорт уже виконується",   "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться",-  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото"+  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото",+  "Open in Compare": "Відкрити в порівнянні",+  "Open in Survey": "Відкрити в огляді",+  "Edit Settings": "Налаштування редагування",+  "Rotate & Flip": "Поворот і віддзеркалення",+  "Copy File Path": "Копіювати шлях до файлу",+  "Copied the file path": "Шлях до файлу скопійовано",+  "The selected photos have no file on disk": "Вибрані фото не мають файлу на диску",+  "Select 2 or more photos": "Виберіть 2 або більше фото",+  "Copy edit settings first": "Спершу скопіюйте налаштування редагування",+  "Copy metadata first": "Спершу скопіюйте метадані",+  "This version of LightCraft can't open the file manager": "Ця версія LightCraft не може відкрити файловий менеджер",+  "This photo has no file on disk": "Це фото не має файлу на диску",+  "This photo is not in a folder of the library": "Це фото не в теці бібліотеки",+  "Opening other programs needs the desktop app": "Відкривати інші програми можна лише в застосунку для комп’ютера",+  "Only a photo file LightCraft reads can be opened this way": "Так можна відкрити лише файл фото, який читає LightCraft" }diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json--- a/crates/ui-egui/locales/vi-formats.json+++ b/crates/ui-egui/locales/vi-formats.json@@ -305,5 +305,6 @@   "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}",   "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",-  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})",+  "Copied {n} file paths": "Đã sao chép {n} đường dẫn tệp" }diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json--- a/crates/ui-egui/locales/vi.json+++ b/crates/ui-egui/locales/vi.json@@ -2235,5 +2235,20 @@   "Drop to open the face model": "Thả để mở mô hình khuôn mặt",   "Dropped Files": "Tệp đã thả",   "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",-  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"+  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",+  "Open in Compare": "Mở so sánh",+  "Open in Survey": "Mở khảo sát",+  "Edit Settings": "Cài đặt chỉnh sửa",+  "Rotate & Flip": "Xoay và lật",+  "Copy File Path": "Sao chép đường dẫn tệp",+  "Copied the file path": "Đã sao chép đường dẫn tệp",+  "The selected photos have no file on disk": "Các ảnh đã chọn không có tệp trên đĩa",+  "Select 2 or more photos": "Chọn từ 2 ảnh trở lên",+  "Copy edit settings first": "Hãy sao chép cài đặt chỉnh sửa trước",+  "Copy metadata first": "Hãy sao chép siêu dữ liệu trước",+  "This version of LightCraft can't open the file manager": "Phiên bản LightCraft này không mở được trình quản lý tệp",+  "This photo has no file on disk": "Ảnh này không có tệp trên đĩa",+  "This photo is not in a folder of the library": "Ảnh này không nằm trong thư mục của thư viện",+  "Opening other programs needs the desktop app": "Mở chương trình khác cần ứng dụng máy tính",+  "Only a photo file LightCraft reads can be opened this way": "Chỉ mở được theo cách này tệp ảnh mà LightCraft đọc được" }diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json--- a/crates/ui-egui/locales/zh-hans-formats.json+++ b/crates/ui-egui/locales/zh-hans-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "无法读取 {name}:{e}",   "Added {n} photo{}": "已添加 {n} 张照片{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?",-  "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}",+  "Copied {n} file paths": "已复制 {n} 个文件路径" }diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json--- a/crates/ui-egui/locales/zh-hans.json+++ b/crates/ui-egui/locales/zh-hans.json@@ -2200,5 +2200,20 @@   "Import dropped files without review": "不经检查直接导入拖入的文件",   "An import is running": "正在导入",   "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片",-  "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入"+  "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入",+  "Open in Compare": "在比较视图中打开",+  "Open in Survey": "在筛选视图中打开",+  "Edit Settings": "编辑设置",+  "Rotate & Flip": "旋转与翻转",+  "Copy File Path": "复制文件路径",+  "Copied the file path": "已复制文件路径",+  "The selected photos have no file on disk": "所选照片在磁盘上没有文件",+  "Select 2 or more photos": "请选择 2 张或更多照片",+  "Copy edit settings first": "请先复制编辑设置",+  "Copy metadata first": "请先复制元数据",+  "This version of LightCraft can't open the file manager": "此版本的 LightCraft 无法打开文件管理器",+  "This photo has no file on disk": "这张照片在磁盘上没有文件",+  "This photo is not in a folder of the library": "这张照片不在图库的文件夹中",+  "Opening other programs needs the desktop app": "打开其他程序需要桌面版",+  "Only a photo file LightCraft reads can be opened this way": "只有 LightCraft 能读取的照片文件才能这样打开" }diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json--- a/crates/ui-egui/locales/zh-hant-formats.json+++ b/crates/ui-egui/locales/zh-hant-formats.json@@ -305,5 +305,6 @@   "Could not read {name}: {e}": "無法讀取 {name}:{e}",   "Added {n} photo{}": "已加入 {n} 張照片{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?",-  "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}"+  "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}",+  "Copied {n} file paths": "已複製 {n} 個檔案路徑" }diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json--- a/crates/ui-egui/locales/zh-hant.json+++ b/crates/ui-egui/locales/zh-hant.json@@ -2157,5 +2157,20 @@   "Import dropped files without review": "不經檢查直接匯入拖放的檔案",   "An import is running": "正在匯入",   "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片",-  "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入"+  "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入",+  "Open in Compare": "在比較檢視中開啟",+  "Open in Survey": "在總覽中開啟",+  "Edit Settings": "編輯設定",+  "Rotate & Flip": "旋轉與翻轉",+  "Copy File Path": "複製檔案路徑",+  "Copied the file path": "已複製檔案路徑",+  "The selected photos have no file on disk": "所選照片在磁碟上沒有檔案",+  "Select 2 or more photos": "請選取 2 張或更多照片",+  "Copy edit settings first": "請先複製編輯設定",+  "Copy metadata first": "請先複製中繼資料",+  "This version of LightCraft can't open the file manager": "此版本的 LightCraft 無法開啟檔案管理員",+  "This photo has no file on disk": "這張照片在磁碟上沒有檔案",+  "This photo is not in a folder of the library": "這張照片不在照片庫的資料夾中",+  "Opening other programs needs the desktop app": "開啟其他程式需要桌面版 App",+  "Only a photo file LightCraft reads can be opened this way": "只有 LightCraft 能讀取的照片檔案才能這樣開啟" }diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -32,6 +32,7 @@ pub mod menus; pub mod merge; mod model_setup; pub mod panels;+pub mod photo_menu; pub mod pick; pub mod print_view; pub mod progress;diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs--- a/crates/ui-egui/src/menubar.rs+++ b/crates/ui-egui/src/menubar.rs@@ -45,7 +45,7 @@ impl MenuNode {  /// User-defined names in parameterized menus are data, not message keys. pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str {-    if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet")+    if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet" | "preset.apply")         || (id == "app.export" && params.get("preset").is_some())         || (id == "app.openLibrary" && params.get("path").is_some())         || (id == "app.recentLibraries" && params.get("remove").is_some())@@ -379,7 +379,7 @@ pub(crate) fn in_normal_album(app: &LightcraftApp) -> bool {     } } -fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {+pub(crate) fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {     MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } } @@ -809,7 +809,7 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> }  /// Resolve a live menu row, including contextual keys and borrowed grid-cycle hints.-fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {+pub(crate) fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {     // Shift+P picks and advances in Library; don't advertise it on Presets in those views.     let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() };     // in an album the Delete key removes from the album (issue #1805): the menu says so@@ -824,7 +824,7 @@ fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { }  /// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus.-fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {+pub(crate) fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {     let mut out: Vec<MenuNode> = Vec::with_capacity(v.len());     for n in v {         let n = match n {diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -282,6 +282,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[     ("app.export", "Export Now", None, ""),     ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),     ("app.showInFolders", "Show in Folders", None, "Photo"),+    // the selected photos' file paths, one per line, on the clipboard+    ("photo.copyPath", "Copy File Path", None, ""),     ("app.openSamModelFolder", "Show in File Manager", None, ""),     ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),     ("dialog.labelNames", "Edit Color Label Names…", None, ""),@@ -1874,7 +1876,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re             r         }         "photo.locate" => {-            let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };+            // `id`: the photo whose file is missing (a photo's menu names it), else the active one+            let id = match photo_param(p, "photo.locate") {+                Ok(id) => id.or(app.session.active()),+                Err(e) => return Some(Err(e)),+            };+            let Some(id) = id else { return Some(Err("no photo selected".into())) };+            if app.session.catalog.photo(id).is_none() {+                return Some(Err(format!("photo.locate: no photo {}", id.0)));+            }             let path = match p.get("path").and_then(Value::as_str) {                 Some(x) => Some(x.to_string()),                 None => {@@ -2263,8 +2273,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re             return Some(r);         }         "app.export" => 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.showInFinder" => photo_param(p, id).and_then(|photo| show_in_finder(app, photo)),+        "app.showInFolders" => photo_param(p, id).and_then(|photo| show_in_folders(app, photo)),+        "photo.copyPath" => copy_file_paths(app, &ctx),         "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);@@ -2311,15 +2322,16 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {                     _ => false,                 })         }-        "app.showInFinder" => {-            app.services.reveal.is_some()-                && app-                    .session-                    .active()-                    .and_then(|id| app.session.catalog.photo(id))-                    .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))-        }+        "app.showInFinder" => show_in_finder_enabled(app, None),         "app.showInFolders" => show_in_folders_enabled(app, None),+        // (asked every frame a menu is open: reads the selection in place, copies no path)+        "photo.copyPath" => {+            let on_disk = |id: &lightcraft_catalog::PhotoId| {+                matches!(app.session.catalog.photo(*id).map(|p| &p.source), Some(lightcraft_engine::catalog::Source::File { .. }))+            };+            let sel = &app.session.selection;+            if sel.ids.is_empty() { sel.active.as_ref().is_some_and(on_disk) } else { sel.ids.iter().any(on_disk) }+        }         "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(),@@ -2437,12 +2449,61 @@ fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> {     Ok(json!({"path": path})) } -/// Reveal the active photo's original in the system file manager.-fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {-    let id = app.session.active().ok_or("no photo selected")?;+/// The selected photos' originals, each once (a virtual copy shares its original's file);+/// generated demo photos and other sources have none.+fn selected_file_paths(app: &LightcraftApp) -> Vec<String> {+    let mut seen = std::collections::HashSet::new();+    app.session+        .selected()+        .into_iter()+        .filter_map(|id| match app.session.catalog.photo(id).map(|p| &p.source) {+            Some(lightcraft_engine::catalog::Source::File { path }) => Some(path.clone()),+            _ => None,+        })+        .filter(|path| seen.insert(path.clone()))+        .collect()+}++/// `photo.copyPath`: the selected photos' file paths, one per line, on the clipboard.+fn copy_file_paths(app: &mut LightcraftApp, ctx: &egui::Context) -> Result<Value, String> {+    let paths = selected_file_paths(app);+    if paths.is_empty() {+        return Err(crate::i18n::tr("The selected photos have no file on disk").to_string());+    }+    ctx.copy_text(paths.join("\n"));+    let n = paths.len();+    let text = if n == 1 { crate::i18n::tr("Copied the file path").to_string() } else { crate::i18n::tr_format!("Copied {n} file paths", n = n) };+    app.toast(ctx, text);+    Ok(json!({"paths": paths}))+}++/// Can Show in Finder reveal `id` (`None`: the active photo)? Needs a file manager and a photo+/// with a file on disk.+pub(crate) fn show_in_finder_enabled(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> bool {+    app.services.reveal.is_some()+        && id+            .or(app.session.active())+            .and_then(|id| app.session.catalog.photo(id))+            .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))+}++/// A command's optional `id` (a photo): none or null means the active photo; anything else must+/// be a photo id, as the engine's commands require, so a mistyped one isn't quietly the active+/// photo.+fn photo_param(p: &Value, cmd: &str) -> Result<Option<lightcraft_catalog::PhotoId>, String> {+    match p.get("id") {+        None | Some(Value::Null) => Ok(None),+        Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| format!("{cmd}: `id` must be a photo id, not {v}")),+    }+}++/// Reveal `id`'s original (`None`: the active photo's) in the system file manager.+fn show_in_finder(app: &mut LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Result<Value, String> {+    let id = id.or(app.session.active()).ok_or("no photo selected")?;     let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) {         Some(lightcraft_engine::catalog::Source::File { path }) => path,-        _ => return Err("this photo has no file (demo scene)".into()),+        Some(_) => return Err("this photo has no file (demo scene)".into()),+        None => return Err(format!("app.showInFinder: no photo {}", id.0)),     };     let reveal = app.services.reveal.as_mut().ok_or("not available here")?;     reveal(&path)?;@@ -2472,6 +2533,11 @@ pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option<lightcraft_catalo /// 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<lightcraft_catalog::PhotoId>) -> Result<Value, String> {+    if let Some(id) = id+        && app.session.catalog.photo(id).is_none()+    {+        return Err(format!("app.showInFolders: no photo {}", id.0));+    }     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 iddiff --git a/crates/ui-egui/src/photo_menu.rs b/crates/ui-egui/src/photo_menu.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/photo_menu.rs@@ -0,0 +1,323 @@+//! The photo context menu: right-click a thumbnail in the grid or the filmstrip, the loupe or a+//! Compare / Survey photo. One menu everywhere, built from the menu bar's model+//! ([`crate::menubar::menu_bar`]), so its rows have the Photo menu's labels ("Delete 3 Photos"),+//! shortcuts, check marks and enabled state. The rows are grouped by what they do (see the+//! photo, describe it, organize it, develop it, its file, where it is, export, delete), with the+//! long tails in submenus (Edit Settings, Metadata, Rotate & Flip). Rows that only make sense+//! somewhere are left out elsewhere: Open in Detail in Detail, Compare and Survey with one photo,+//! the album rows outside an album, Locate Missing File… for a file that is there.+//!+//! This module builds the rows ([`nodes`]) and says why an off row is off ([`disabled_reason`]);+//! the right-click menus of the grid, the filmstrip, the loupe and Compare / Survey draw them in+//! the change that follows (parity row MENU-CTX-GRID).++use lightcraft_catalog::{Flag, PhotoId};+use lightcraft_engine::cmd::CommandInfo;+use serde_json::{Value, json};++use crate::LightcraftApp;+use crate::menubar::{self, MenuNode};+use crate::menus::MenuEntry;+use crate::state::ViewMode;++/// The first item for `id` in `nodes`, submenus included.+fn find_item<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> {+    nodes.iter().find_map(|n| match n {+        MenuNode::Item { id: i, .. } if i == id => Some(n),+        MenuNode::Submenu { children, .. } => find_item(children, id),+        _ => None,+    })+}++/// Any registered command as a row, with or without a place in the menu bar.+fn registered(app: &LightcraftApp, infos: &[CommandInfo], id: &str) -> Option<MenuNode> {+    let keymap = &app.ui.settings.keymap;+    let e = match crate::menus::ui_commands().find(|c| c.0 == id) {+        Some((cid, label, sc, _)) => MenuEntry {+            id: cid.to_string(),+            label: label.to_string(),+            menu: Vec::new(),+            shortcut: crate::shortcuts::binding(keymap, cid, *sc).map(str::to_string),+            enabled: crate::menus::ui_enabled(app, cid),+        },+        None => {+            let c = infos.iter().find(|c| c.id == id)?;+            MenuEntry {+                id: c.id.into(),+                label: c.label.into(),+                menu: Vec::new(),+                shortcut: crate::shortcuts::shortcut_of(keymap, c.id).map(str::to_string),+                enabled: c.enabled,+            }+        }+    };+    Some(menubar::node(app, &e))+}++/// `n` with its label, parameters, enabled or checked state changed.+fn with(n: Option<MenuNode>, f: impl FnOnce(&mut String, &mut Value, &mut bool, &mut Option<bool>)) -> Option<MenuNode> {+    let mut n = n?;+    if let MenuNode::Item { label, params, enabled, checked, .. } = &mut n {+        f(label, params, enabled, checked);+    }+    Some(n)+}++/// The rating, flag and label rows checked by `photo`'s values (the menu bar checks the active+/// photo's, which a right-click on one of several selected photos may leave another). In Compare+/// and Survey a click there rates the active photo only (`compare::target_active`), so these rows+/// name `photo`: the one checked is the one changed.+fn recheck(app: &LightcraftApp, photo: PhotoId, mut sub: MenuNode) -> MenuNode {+    let culling = crate::panels::compare::culling(app);+    let Some(p) = app.session.catalog.photo(photo) else { return sub };+    if let MenuNode::Submenu { children, .. } = &mut sub {+        for c in children {+            let MenuNode::Item { id, params, checked, .. } = c else { continue };+            let on = match id.as_str() {+                "photo.rate" => params["rating"].as_u64().map(|r| r == u64::from(p.rating)),+                "photo.flag" => params["flag"].as_str().map(|f| match p.flag {+                    Flag::Pick => f == "pick",+                    Flag::Reject => f == "reject",+                    Flag::None => f == "none",+                }),+                "photo.label" => params["label"].as_str().map(|l| match p.label {+                    Some(c) => format!("{c:?}").to_lowercase() == l,+                    None => l == "none",+                }),+                _ => None,+            };+            if on.is_some() {+                *checked = on;+                if culling {+                    params["ids"] = json!([photo.0]);+                }+            }+        }+    }+    sub+}++/// Edit Settings ▸ Apply Preset: one submenu per preset group, in the Presets panel's order+/// (Favorites first, then the groups by name). Labels are in the UI language already (a user's+/// group and preset names as written): whatever draws them must not translate them again+/// ([`menubar::display_item_label`] leaves `preset.apply` rows alone).+fn presets(app: &LightcraftApp, any: bool) -> MenuNode {+    let list = &app.session.presets;+    let row = |p: &lightcraft_develop::Preset| {+        menubar::item("preset.apply", json!({"id": p.id}), crate::i18n::builtin_label(&p.name, p.builtin), None, any, None)+    };+    let mut by_name: std::collections::BTreeMap<&str, (bool, Vec<MenuNode>)> = std::collections::BTreeMap::new();+    for p in list.iter() {+        let g = by_name.entry(&p.group).or_default();+        g.0 |= p.builtin;+        g.1.push(row(p));+    }+    let starred: Vec<MenuNode> = list.iter().filter(|p| p.favorite).map(row).collect();+    let favorites = (!starred.is_empty()).then(|| MenuNode::Submenu { label: crate::i18n::tr("Favorites").into(), children: starred });+    let groups = by_name+        .into_iter()+        .map(|(name, (builtin, children))| MenuNode::Submenu { label: crate::i18n::builtin_label(name, builtin).into(), children });+    MenuNode::Submenu { label: "Apply Preset".into(), children: favorites.into_iter().chain(groups).collect() }+}++/// The menu for `photo` (the right-click selects it unless it is one of the selected photos).+pub fn nodes(app: &LightcraftApp, photo: PhotoId) -> Vec<MenuNode> {+    let any = !app.session.selected().is_empty();+    nodes_with(app, photo, &app.session.commands(), presets(app, any))+}++/// [`nodes`], with `infos` ([`lightcraft_engine::Session::commands`]) and Apply Preset at hand.+fn nodes_with(app: &LightcraftApp, photo: PhotoId, infos: &[CommandInfo], presets: MenuNode) -> Vec<MenuNode> {+    let bar = menubar::menu_bar(app);+    let menu = |title: &str| bar.iter().find(|(t, _)| t == title).map(|(_, n)| n.as_slice()).unwrap_or_default();+    // a menu's own row before one of the same command inside a submenu (Photo ▸ Add to Album holds+    // a hint row for Add to My Photos)+    let top = |id: &str| bar.iter().find_map(|(_, n)| n.iter().find(|x| matches!(x, MenuNode::Item { id: i, .. } if i == id))).cloned();+    let row = |id: &str| top(id).or_else(|| bar.iter().find_map(|(_, n)| find_item(n, id)).cloned()).or_else(|| registered(app, infos, id));+    let sub = |title: &str, label: &str| menu(title).iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)).cloned();+    let submenu =+        |label: &str, children: Vec<Option<MenuNode>>| MenuNode::Submenu { label: label.into(), children: children.into_iter().flatten().collect() };+    let selected = app.session.selected();+    let several = selected.len() > 1;+    let view = app.ui.view;+    let sep = || Some(MenuNode::Separator);+    let open = |id: &str, label: &'static str| {+        with(row(id), |l, _, _, c| {+            *l = label.into();+            *c = None;+        })+    };+    let mut v: Vec<Option<MenuNode>> = Vec::new();++    // see it+    if view != ViewMode::Detail {+        v.push(open("view.detail", "Open in Detail"));+    }+    if several && view != ViewMode::Compare {+        v.push(open("view.compare", "Open in Compare"));+    }+    if several && view != ViewMode::Survey {+        v.push(open("view.survey", "Open in Survey"));+    }+    v.push(sub("Photo", "Edit In"));+    v.push(sep());++    // describe it+    for s in ["Set Rating", "Set Flag", "Set Color Label"] {+        v.push(sub("Photo", s).map(|s| recheck(app, photo, s)));+    }+    v.push(row("panel.addKeywords"));+    let copied_meta = app.session.meta_clipboard.is_some();+    v.push(Some(submenu(+        "Metadata",+        vec![+            row("photo.copyMetadata"),+            with(row("photo.pasteMetadata"), |_, _, e, _| *e &= copied_meta),+            sub("Photo", "Metadata Preset"),+            sep(),+            row("dialog.captureTime"),+            row("photo.tagFromTracklog"),+            row("metadata.reverseGeocode"),+            sep(),+            row("photo.saveMetadataToFile"),+            row("photo.readMetadataFromFile"),+            sep(),+            row("dialog.allMetadata"),+            row("faces.detect"),+        ],+    )));+    v.push(sep());++    // organize it+    // (offered when the right-clicked photo is Local: a folder being browsed shows Local photos+    // alone, so a mixed selection is rare, and Photo ▸ Add to Album says the rest)+    if app.session.catalog.photo(photo).is_some_and(|p| p.local) {+        v.push(row("photo.addToLibrary"));+    }+    v.push(row("album.toggleTarget"));+    if crate::panels::grid::local_only(app, &selected) {+        // albums hold library photos (issue #684): the row is off and says to add them first+        v.push(Some(menubar::item("album.addPhotos", Value::Null, crate::i18n::tr("Add to Album"), None, false, None)));+    } else {+        v.push(sub("Photo", "Add to Album"));+    }+    if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source+        && menubar::in_normal_album(app)+        && !menubar::selection_deleted(app)+    {+        v.push(row("album.removePhotos"));+        v.push(with(row("album.setCover"), |_, p, _, _| *p = json!({"id": aid.0, "photo": photo.0})));+    }+    v.push(sub("Photo", "Stack"));+    v.push(with(row("library.findSimilar"), |_, p, _, _| *p = json!({"id": photo.0})));+    v.push(sep());++    // develop it+    let copied = app.session.clipboard.is_some();+    v.push(Some(submenu(+        "Edit Settings",+        vec![+            row("develop.copy"),+            row("dialog.copySettings"),+            with(row("develop.paste"), |_, _, e, _| *e &= copied),+            with(row("dialog.pasteSettings"), |_, _, e, _| *e &= copied),+            row("develop.pastePrevious"),+            row("develop.sync"),+            row("develop.matchExposure"),+            sep(),+            row("develop.auto"),+            row("develop.treatment"),+            Some(presets),+            row("dialog.createPreset"),+            sep(),+            with(row("photo.setReference"), |_, p, e, _| {+                *p = json!({"id": photo.0});+                *e &= crate::panels::compare::live(app, photo);+            }),+            row("develop.reset"),+        ],+    )));+    v.push(row("photo.virtualCopy"));+    v.push(row("version.create"));+    v.push(sub("Photo", "Photo Merge"));+    v.push(Some(submenu(+        "Rotate & Flip",+        vec![row("photo.rotateLeft"), row("photo.rotateRight"), sep(), row("photo.flipHorizontal"), row("photo.flipVertical")],+    )));+    v.push(sep());++    // its file+    v.push(row("dialog.rename"));+    if app.services.pick_folder.is_some() || app.services.picker.is_some() {+        v.push(with(row("photo.moveToFolder"), |l, _, _, _| *l = "Move to Folder…".into()));+    }+    v.push(row("photo.duplicate"));+    v.push(row("photo.convertToDng"));+    v.push(row("photo.reload"));+    v.push(sep());++    // where it is: the original moved or its drive is gone (a cached answer, checked off the UI thread)+    let missing = matches!(app.session.catalog.photo(photo).map(|p| &p.source), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path));+    if missing {+        v.push(with(row("photo.locate"), |_, p, _, _| *p = json!({"id": photo.0})));+    }+    v.push(with(row("app.showInFinder"), |_, p, e, _| {+        *p = json!({"id": photo.0});+        *e = crate::menus::show_in_finder_enabled(app, Some(photo));+    }));+    v.push(with(row("app.showInFolders"), |_, p, e, _| {+        *p = json!({"id": photo.0});+        *e = crate::menus::show_in_folders_enabled(app, Some(photo));+    }));+    v.push(row("photo.copyPath"));+    v.push(sep());++    // export it+    v.push(row("dialog.export"));+    let mut export = vec![row("app.exportPrevious"), sep()];+    if let Some(MenuNode::Submenu { children, .. }) = sub("File", "Export with Preset") {+        // (its Custom… is the Export… row above)+        export.extend(children.into_iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")).map(Some));+    }+    v.push(Some(submenu("Export with Preset", export)));+    v.push(sep());++    // delete it (Restore and Delete Permanently in Recently Deleted: the menu bar's choice)+    for id in ["photo.delete", "photo.restore", "photo.deletePermanently"] {+        v.push(menu("Photo").iter().find(|n| matches!(n, MenuNode::Item { id: i, .. } if i == id)).cloned());+    }+    menubar::tidy(v.into_iter().flatten().collect())+}++/// Why a row that is off is off; `infos` is [`lightcraft_engine::Session::commands`]. Asked only+/// about a row that is off: for some ids the answer doesn't depend on the row being off.+fn reason(app: &LightcraftApp, infos: &[CommandInfo], id: &str, params: &Value) -> Option<String> {+    let why = match id {+        _ if (id.starts_with("dialog.merge") || id.starts_with("merge.")) && app.session.selected().len() < 2 => "Select 2 or more photos",+        "develop.paste" | "dialog.pasteSettings" if app.session.clipboard.is_none() => "Copy edit settings first",+        "photo.pasteMetadata" if app.session.meta_clipboard.is_none() => "Copy metadata first",+        "album.addPhotos" if params.is_null() => return Some(crate::panels::grid::albums_hint()),+        "photo.copyPath" => "The selected photos have no file on disk",+        "app.showInFinder" if app.services.reveal.is_none() => "This version of LightCraft can't open the file manager",+        "app.showInFinder" => "This photo has no file on disk",+        "app.showInFolders" => "This photo is not in a folder of the library",+        "photo.editInPhotoCraft" | "photo.editInExternal" | "photo.openOriginalInExternal" if app.services.open_with.is_none() => {+            "Opening other programs needs the desktop app"+        }+        "photo.openOriginalInExternal" => "Only a photo file LightCraft reads can be opened this way",+        "photo.setReference" => "Restore the photo to use it as the reference",+        _ => return infos.iter().find(|c| c.id == id).and_then(|c| c.disabled_reason.clone()),+    };+    Some(crate::i18n::tr(why).to_string())+}++/// Why a row is off (the text shown while the pointer rests on a row that is off). Asked only+/// about a row that is off: for some ids (Copy File Path, Show in Folders, Set as Reference) the+/// answer is the same whether or not it is.+pub fn disabled_reason(app: &LightcraftApp, id: &str, params: &Value) -> Option<String> {+    reason(app, &app.session.commands(), id, params)+}++#[cfg(test)]+#[path = "photo_menu_tests.rs"]+mod tests;diff --git a/crates/ui-egui/src/photo_menu_tests.rs b/crates/ui-egui/src/photo_menu_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/photo_menu_tests.rs@@ -0,0 +1,503 @@+//! Tests of the photo menu's model ([`super::nodes`], [`super::disabled_reason`]): what it offers, how it+//! groups it, and that it matches the menu bar.++use super::*;+use crate::state::ViewMode;+use serde_json::json;++fn app() -> LightcraftApp {+    LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default())+}++fn photos(a: &mut LightcraftApp) -> Vec<PhotoId> {+    a.session.visible_cloned()+}++fn select(a: &mut LightcraftApp, ids: &[PhotoId]) {+    a.run("library.select", json!({"ids": ids.iter().map(|p| p.0).collect::<Vec<_>>()})).unwrap();+}++/// Every item, submenus included, with the submenu labels it sits under.+fn items(nodes: &[MenuNode]) -> Vec<(Vec<String>, MenuNode)> {+    fn walk(n: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<String>, MenuNode)>) {+        for x in n {+            match x {+                MenuNode::Submenu { label, children } => {+                    path.push(label.clone());+                    walk(children, path, out);+                    path.pop();+                }+                MenuNode::Item { .. } => out.push((path.clone(), x.clone())),+                MenuNode::Separator => {}+            }+        }+    }+    let mut out = Vec::new();+    walk(nodes, &mut Vec::new(), &mut out);+    out+}++fn find<'a>(nodes: &'a [(Vec<String>, MenuNode)], id: &str, params: &Value) -> Option<&'a MenuNode> {+    nodes.iter().map(|(_, n)| n).find(|n| matches!(n, MenuNode::Item { id: i, params: p, .. } if i == id && (params.is_null() || p == params)))+}++fn has(a: &LightcraftApp, photo: PhotoId, id: &str) -> bool {+    find(&items(&nodes(a, photo)), id, &Value::Null).is_some()+}++/// What the Photo menu can do to the selected photos, the right-click menu can do too (a mode+/// like Auto Advance and library-wide tools aside), so a new Photo command can't be left out.+#[test]+fn the_photo_menu_actions_are_all_offered() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    let bar = crate::menubar::menu_bar(&a);+    let menu = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1;+    let ours = items(&nodes(&a, p));+    // a mode, library-wide tools, and Add to My Photos (offered only for Local photos)+    let not_for_a_photo =+        ["view.autoAdvance", "dialog.cull", "photo.readFocusPoints", "develop.updateProcess", "metadata.downloadPlaceNames", "photo.addToLibrary"];+    let missing: Vec<String> = items(menu)+        .into_iter()+        .filter_map(|(_, n)| match n {+            MenuNode::Item { id, params, .. } if !not_for_a_photo.contains(&id.as_str()) && find(&ours, &id, &params).is_none() => {+                Some(MenuNode::key(&id, &params))+            }+            _ => None,+        })+        .collect();+    assert!(missing.is_empty(), "the Photo menu has these and the right-click menu doesn't: {missing:?}");+}++/// The everyday actions a photo library's menu has beyond the Photo menu: metadata, edit+/// settings, the target album, Compare and the file's path.+#[test]+fn copy_paste_sync_and_the_target_album_are_offered() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &[v[0], v[1]]);+    let ours = items(&nodes(&a, v[0]));+    for id in [+        "photo.copyMetadata",+        "photo.pasteMetadata",+        "develop.copy",+        "develop.paste",+        "dialog.pasteSettings",+        "develop.sync",+        "develop.auto",+        "album.toggleTarget",+        "view.compare",+        "view.survey",+        "photo.duplicate",+        "photo.convertToDng",+        "photo.flipHorizontal",+        "dialog.captureTime",+        "photo.copyPath",+        "app.exportPrevious",+    ] {+        assert!(find(&ours, id, &Value::Null).is_some(), "{id} is missing");+    }+    // presets, by group+    let preset = a.session.presets.first().expect("demo presets").id.clone();+    assert!(find(&ours, "preset.apply", &json!({"id": preset})).is_some(), "Apply Preset lists the presets");+}++/// Groups stay short and the long tails sit in submenus: Edit Settings and Metadata.+#[test]+fn actions_are_grouped_with_the_long_tails_in_submenus() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    let n = nodes(&a, p);+    let ours = items(&n);+    let under = |id: &str| ours.iter().find(|(_, x)| matches!(x, MenuNode::Item { id: i, .. } if i == id)).map(|(path, _)| path.clone());+    assert_eq!(under("develop.copy"), Some(vec!["Edit Settings".to_string()]));+    assert_eq!(under("develop.reset"), Some(vec!["Edit Settings".to_string()]));+    assert_eq!(under("photo.copyMetadata"), Some(vec!["Metadata".to_string()]));+    assert_eq!(under("dialog.captureTime"), Some(vec!["Metadata".to_string()]));+    assert_eq!(under("photo.flipVertical"), Some(vec!["Rotate & Flip".to_string()]));+    let mut group = 0;+    for x in &n {+        if *x == MenuNode::Separator {+            group = 0;+        } else {+            group += 1;+            assert!(group <= 6, "a group of more than 6 rows: {n:?}");+        }+    }+    // Delete stands alone at the end+    assert!(matches!(n.last(), Some(MenuNode::Item { id, .. }) if id == "photo.delete"), "{:?}", n.last());+    assert_eq!(n.get(n.len().saturating_sub(2)), Some(&MenuNode::Separator));+}++/// The labels, shortcuts and enabled state are the menu bar's (one model): "Delete 3 Photos".+#[test]+fn rows_have_the_menu_bars_labels_and_shortcuts() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &v[..3]);+    let ours = items(&nodes(&a, v[0]));+    let bar = crate::menubar::menu_bar(&a);+    // the Photo menu's rows first (its Add to Album ▸ New Album… has no shortcut, File's has)+    let theirs: Vec<_> =+        bar.iter().filter(|(t, _)| t == "Photo").chain(bar.iter().filter(|(t, _)| t != "Photo")).flat_map(|(_, n)| items(n)).collect();+    let mut compared = 0;+    for (_, n) in &ours {+        let MenuNode::Item { id, params, label, shortcut, .. } = n else { continue };+        if id.starts_with("view.") {+            continue; // "Open in Detail" rather than the View menu's "Detail View"+        }+        if let Some(MenuNode::Item { label: l, shortcut: s, .. }) = find(&theirs, id, params) {+            assert_eq!((label, shortcut), (l, s), "{id}");+            compared += 1;+        }+    }+    assert!(compared > 20, "only {compared} rows compared");+    let delete = find(&ours, "photo.delete", &Value::Null).expect("delete");+    assert!(matches!(delete, MenuNode::Item { label, shortcut: Some(_), .. } if label == "Delete 3 Photos"), "{delete:?}");+}++/// The rating, flag and label submenus check the right-clicked photo's values, also when it+/// isn't the active one of a selection.+#[test]+fn rating_flag_and_label_check_the_right_clicked_photo() {+    let mut a = app();+    let v = photos(&mut a);+    let (active, clicked) = (v[0], v[1]);+    a.run("photo.rate", json!({"ids": [clicked.0], "rating": 4})).unwrap();+    a.run("photo.flag", json!({"ids": [clicked.0], "flag": "pick"})).unwrap();+    a.run("photo.label", json!({"ids": [clicked.0], "label": "green"})).unwrap();+    a.run("photo.rate", json!({"ids": [active.0], "rating": 1})).unwrap();+    select(&mut a, &[active, clicked]);+    assert_eq!(a.session.active(), Some(active));+    let ours = items(&nodes(&a, clicked));+    let checked = |id: &str, params: Value| match find(&ours, id, &params) {+        Some(MenuNode::Item { checked, .. }) => *checked,+        other => panic!("{id} {params}: {other:?}"),+    };+    assert_eq!(checked("photo.rate", json!({"rating": 4})), Some(true));+    assert_eq!(checked("photo.rate", json!({"rating": 1})), Some(false));+    assert_eq!(checked("photo.flag", json!({"flag": "pick"})), Some(true));+    assert_eq!(checked("photo.label", json!({"label": "green"})), Some(true));+    assert_eq!(checked("photo.label", json!({"label": "none"})), Some(false));+}++/// Only what makes sense where the menu opened: no "Open in Detail" in Detail, Compare and+/// Survey once several photos are chosen.+#[test]+fn view_rows_follow_the_view_and_the_selection() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &[v[0]]);+    a.ui.view = ViewMode::PhotoGrid;+    assert!(has(&a, v[0], "view.detail"));+    assert!(!has(&a, v[0], "view.compare"), "Compare needs two photos");+    a.ui.view = ViewMode::Detail;+    assert!(!has(&a, v[0], "view.detail"), "already in Detail (the filmstrip)");+    select(&mut a, &[v[0], v[1]]);+    assert!(has(&a, v[0], "view.compare") && has(&a, v[0], "view.survey"));+    a.ui.view = ViewMode::Compare;+    assert!(!has(&a, v[0], "view.compare"), "already comparing");+    let detail = find(&items(&nodes(&a, v[0])), "view.detail", &Value::Null).cloned();+    assert!(matches!(&detail, Some(MenuNode::Item { label, checked: None, .. }) if label == "Open in Detail"), "{detail:?}");+}++/// A row that is off says why, where the reason isn't obvious.+#[test]+fn rows_that_are_off_say_why() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    let ours = items(&nodes(&a, p));+    for id in ["dialog.mergeHdr", "develop.paste", "photo.copyPath"] {+        assert!(matches!(find(&ours, id, &Value::Null), Some(MenuNode::Item { enabled: false, .. })), "{id} should be off here");+        assert!(disabled_reason(&a, id, &Value::Null).is_some_and(|r| !r.is_empty()), "{id} says nothing");+    }+    assert_eq!(disabled_reason(&a, "dialog.mergeHdr", &Value::Null).as_deref(), Some("Select 2 or more photos"));+}++/// A photo in Recently Deleted gets Restore and Delete Permanently instead of Delete.+#[test]+fn a_deleted_photo_offers_restore() {+    let mut a = app();+    a.ui.settings.confirm_delete = false;+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    a.run("photo.delete", json!({})).unwrap();+    select(&mut a, &[p]);+    assert!(has(&a, p, "photo.restore") && has(&a, p, "photo.deletePermanently"));+    assert!(!has(&a, p, "photo.delete"));+}++/// Give a photo a file on disk (the path is only read, never opened).+fn on_disk(a: &mut LightcraftApp, id: PhotoId, path: &str) {+    let name = path.rsplit('/').next().unwrap_or(path).to_string();+    a.session+        .catalog+        .apply(lightcraft_catalog::Op::SetFile { id, file_name: name, source: lightcraft_catalog::Source::File { path: path.into() } })+        .unwrap();+}++/// The rating, flag and label rows with their parameters.+fn culling_rows(a: &LightcraftApp, photo: PhotoId) -> Vec<Value> {+    items(&nodes(a, photo))+        .into_iter()+        .filter_map(|(_, n)| match n {+            MenuNode::Item { id, params, .. } if matches!(id.as_str(), "photo.rate" | "photo.flag" | "photo.label") => Some(params),+            _ => None,+        })+        .collect()+}++/// In Compare and Survey a rating, flag or label row names the right-clicked photo (a click+/// there would otherwise change only the active one); elsewhere it changes the selection.+#[test]+fn culling_rows_name_the_photo_in_compare_and_survey() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &[v[0], v[1]]);+    let rows = culling_rows(&a, v[1]);+    assert!(!rows.is_empty() && rows.iter().all(|p| p.get("ids").is_none()), "in the grid: {rows:?}");+    for view in [ViewMode::Compare, ViewMode::Survey] {+        a.ui.view = view;+        let rows = culling_rows(&a, v[1]);+        assert!(!rows.is_empty() && rows.iter().all(|p| p["ids"] == json!([v[1].0])), "{view:?}: {rows:?}");+    }+}++/// Add to My Photos is offered for a Local photo only; a selection of Local photos gets an Add to+/// Album row that is off and says to add them first.+#[test]+fn local_photos_are_offered_add_to_my_photos() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    assert!(!has(&a, p, "photo.addToLibrary"));+    a.session.catalog.apply(lightcraft_catalog::Op::SetLocal { id: p, local: true }).unwrap();+    let ours = items(&nodes(&a, p));+    assert!(matches!(find(&ours, "photo.addToLibrary", &Value::Null), Some(MenuNode::Item { enabled: true, .. })));+    assert!(matches!(find(&ours, "album.addPhotos", &Value::Null), Some(MenuNode::Item { enabled: false, .. })));+    assert_eq!(disabled_reason(&a, "album.addPhotos", &Value::Null), Some(crate::panels::grid::albums_hint()));+}++/// Inside an album, Remove from Album and Set as Album Cover (of that album, with this photo);+/// outside one, neither.+#[test]+fn album_rows_only_inside_an_album() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &[v[2]]);+    assert!(!has(&a, v[2], "album.removePhotos") && !has(&a, v[2], "album.setCover"));+    let album = a.session.execute("album.create", &json!({"name": "Kept"})).unwrap()["id"].as_u64().unwrap();+    a.session.execute("album.addPhotos", &json!({"id": album, "ids": [v[2].0]})).unwrap();+    a.run("library.source", json!({"kind": "album", "id": album})).unwrap();+    select(&mut a, &[v[2]]);+    assert!(has(&a, v[2], "album.removePhotos"));+    let ours = items(&nodes(&a, v[2]));+    assert!(find(&ours, "album.setCover", &json!({"id": album, "photo": v[2].0})).is_some(), "the cover row names the album and the photo");+}++/// Locate Missing File… only for a photo whose original is known to be gone.+#[test]+fn locate_only_for_a_missing_original() {+    let mut a = app();+    let v = photos(&mut a);+    on_disk(&mut a, v[0], "/pics/here.jpg");+    on_disk(&mut a, v[1], "/pics/gone.jpg");+    a.session.media.availability.set_probe(std::sync::Arc::new(|path: &str| path != "/pics/gone.jpg"));+    select(&mut a, &[v[0]]);+    assert!(!has(&a, v[0], "photo.locate"));+    select(&mut a, &[v[1]]);+    assert!(has(&a, v[1], "photo.locate"));+}++/// The rows about one photo carry it: Find Similar, Show in Folders and Set as Reference ask for+/// the right-clicked photo, not the active one.+#[test]+fn photo_rows_carry_the_right_clicked_photo() {+    let mut a = app();+    let v = photos(&mut a);+    select(&mut a, &[v[0], v[3]]);+    assert_eq!(a.session.active(), Some(v[0]));+    let ours = items(&nodes(&a, v[3]));+    for id in ["library.findSimilar", "app.showInFolders", "photo.setReference"] {+        assert!(find(&ours, id, &json!({"id": v[3].0})).is_some(), "{id} names the right-clicked photo");+    }+}++/// The menu row of a selected photo that isn't the active one, with its own parameters.+fn row_of(a: &LightcraftApp, photo: PhotoId, id: &str) -> (Value, bool) {+    match find(&items(&nodes(a, photo)), id, &Value::Null) {+        Some(MenuNode::Item { params, enabled, .. }) => (params.clone(), *enabled),+        other => panic!("no {id} row: {other:?}"),+    }+}++/// Right-clicking a selected photo that isn't the active one, Show in Finder is on for that+/// photo's file (the active photo has none) and reveals it; a mistyped `id` is refused, not taken+/// for the active photo.+#[test]+fn show_in_finder_reveals_the_right_clicked_photo() {+    let mut a = app();+    let v = photos(&mut a);+    on_disk(&mut a, v[1], "/pics/clicked.jpg");+    let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));+    let s = shown.clone();+    a.services.reveal = Some(Box::new(move |p: &str| {+        s.lock().unwrap().push(p.to_string());+        Ok(())+    }));+    select(&mut a, &[v[0], v[1]]);+    assert_eq!(a.session.active(), Some(v[0]));+    assert!(!crate::menus::ui_enabled(&a, "app.showInFinder"), "the menu bar's is off: the active photo has no file");+    let (finder, on) = row_of(&a, v[1], "app.showInFinder");+    assert!(on, "the right-clicked photo's row is on");+    a.run("app.showInFinder", finder).unwrap();+    assert_eq!(shown.lock().unwrap().as_slice(), ["/pics/clicked.jpg"]);+    // a mistyped id is refused, not taken for the active photo; an unknown one says so+    on_disk(&mut a, v[0], "/pics/active.jpg");+    let e = a.run("app.showInFinder", json!({"id": "5"})).unwrap_err();+    assert!(e.contains("must be a photo id"), "{e}");+    let e = a.run("app.showInFinder", json!({"id": u64::MAX})).unwrap_err();+    assert!(e.contains("no photo"), "{e}");+    assert_eq!(shown.lock().unwrap().as_slice(), ["/pics/clicked.jpg"], "nothing else was revealed");+}++/// Right-clicking a selected photo that isn't the active one, Locate Missing File… relinks that+/// photo, and leaves the active one alone.+#[test]+fn locate_relinks_the_right_clicked_photo() {+    let mut a = app();+    let v = photos(&mut a);+    let dir = std::env::temp_dir().join(format!("lc-photo-menu-locate-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let found = dir.join("found.jpg");+    std::fs::write(&found, b"not read").unwrap();+    on_disk(&mut a, v[0], "/pics/active.jpg");+    on_disk(&mut a, v[1], "/pics/gone.jpg");+    a.session.media.availability.set_probe(std::sync::Arc::new(|path: &str| path != "/pics/gone.jpg"));+    select(&mut a, &[v[0], v[1]]);+    assert_eq!(a.session.active(), Some(v[0]));+    let (mut locate, _) = row_of(&a, v[1], "photo.locate");+    locate["path"] = json!(found.to_string_lossy());+    a.run("photo.locate", locate).unwrap();+    let path = |a: &LightcraftApp, id: PhotoId| match a.session.catalog.photo(id).map(|p| &p.source) {+        Some(lightcraft_catalog::Source::File { path }) => path.clone(),+        _ => String::new(),+    };+    assert_eq!(path(&a, v[1]), found.to_string_lossy(), "the right-clicked photo is relinked");+    assert_eq!(path(&a, v[0]), "/pics/active.jpg", "the active photo is left alone");+    // an unknown or mistyped id is refused before any file dialog opens+    a.services.pick_files = Some(Box::new(|| panic!("no file dialog for a photo that doesn't exist")));+    let e = a.run("photo.locate", json!({"id": u64::MAX})).unwrap_err();+    assert!(e.contains("no photo"), "{e}");+    let e = a.run("photo.locate", json!({"id": u64::MAX, "path": found.to_string_lossy()})).unwrap_err();+    assert!(e.contains("no photo"), "{e}");+    let e = a.run("photo.locate", json!({"id": "5", "path": found.to_string_lossy()})).unwrap_err();+    assert!(e.contains("must be a photo id"), "{e}");+    assert_eq!(path(&a, v[0]), "/pics/active.jpg", "still left alone");+    let _ = std::fs::remove_dir_all(&dir);+}++/// Set as Reference Photo is off for a photo in Recently Deleted, and says why.+#[test]+fn a_deleted_photo_cant_be_the_reference() {+    let mut a = app();+    a.ui.settings.confirm_delete = false;+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    a.run("photo.delete", json!({})).unwrap();+    select(&mut a, &[p]);+    let ours = items(&nodes(&a, p));+    assert!(matches!(find(&ours, "photo.setReference", &Value::Null), Some(MenuNode::Item { enabled: false, .. })));+    assert_eq!(disabled_reason(&a, "photo.setReference", &json!({"id": p.0})).as_deref(), Some("Restore the photo to use it as the reference"));+}++/// Export with Preset holds Export with Previous and the presets, not a second Export… (the row+/// above it is that).+#[test]+fn export_with_preset_has_no_second_export() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    let ours = items(&nodes(&a, p));+    let export: Vec<&MenuNode> = ours.iter().filter(|(path, _)| path.iter().any(|s| s == "Export with Preset")).map(|(_, n)| n).collect();+    assert!(export.iter().any(|n| matches!(n, MenuNode::Item { id, .. } if id == "app.exportPrevious")));+    assert!(!export.iter().any(|n| matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")));+    assert_eq!(ours.iter().filter(|(_, n)| matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")).count(), 1);+}++/// Apply Preset lists the groups as the Presets panel does: Favorites first, then by name.+#[test]+fn apply_preset_groups_follow_the_presets_panel() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    let star = a.session.presets.iter().position(|x| x.builtin).expect("a stock preset");+    a.session.presets[star].favorite = true;+    let starred = a.session.presets[star].id.clone();+    let mut groups: Vec<String> = a.session.presets.iter().map(|x| x.group.clone()).collect();+    groups.sort_unstable();+    groups.dedup();+    let apply = nodes(&a, p)+        .into_iter()+        .find_map(|n| match n {+            MenuNode::Submenu { label, children } if label == "Edit Settings" => children.into_iter().find_map(|c| match c {+                MenuNode::Submenu { label, children } if label == "Apply Preset" => Some(children),+                _ => None,+            }),+            _ => None,+        })+        .expect("Edit Settings ▸ Apply Preset");+    let labels: Vec<&str> = apply.iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.as_str()) } else { None }).collect();+    let mut want = vec!["Favorites"];+    want.extend(groups.iter().map(String::as_str));+    assert_eq!(labels, want);+    let MenuNode::Submenu { children, .. } = &apply[0] else { panic!("Favorites") };+    assert!(children.iter().any(|c| matches!(c, MenuNode::Item { params, .. } if params["id"] == json!(starred))));+}++/// The reasons of rows that need the desktop app or a file say so.+#[test]+fn host_and_file_reasons() {+    let mut a = app();+    let p = photos(&mut a)[0];+    select(&mut a, &[p]);+    assert!(a.services.reveal.is_none() && a.services.open_with.is_none(), "a test app has neither");+    let why = |id: &str| disabled_reason(&a, id, &json!({"id": p.0}));+    assert_eq!(why("app.showInFinder").as_deref(), Some("This version of LightCraft can't open the file manager"));+    assert_eq!(why("photo.openOriginalInExternal").as_deref(), Some("Opening other programs needs the desktop app"));+    assert_eq!(why("app.showInFolders").as_deref(), Some("This photo is not in a folder of the library"));+}++/// Copy File Path puts each selected photo's path on the clipboard once (a virtual copy shares its+/// original's file), one per line, and says how many.+#[test]+fn copy_file_path_copies_each_file_once() {+    let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });+    let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);+    let v = h.app.session.visible_cloned();+    on_disk(&mut h.app, v[0], "/pics/a.jpg");+    on_disk(&mut h.app, v[1], "/pics/b.jpg");+    let ask = |h: &mut crate::headless::Headless, ids: Vec<u64>| {+        h.app.session.execute("library.select", &json!({"ids": ids})).unwrap();+        let r = h.request("engine.execute", json!({"command": "photo.copyPath"}), std::time::Duration::from_secs(20));+        h.step();+        h.step();+        r+    };+    // (a frame first: commands run outside a frame copy through the context it last drew)+    h.settle(std::time::Duration::from_secs(120));+    let r = ask(&mut h, vec![v[0].0]);+    assert_eq!(r["result"]["paths"], json!(["/pics/a.jpg"]), "{r}");+    assert_eq!(h.app.copied.as_deref(), Some("/pics/a.jpg"));+    assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Copied the file path"));+    h.app.session.execute("library.select", &json!({"ids": [v[0].0]})).unwrap();+    let copy = h.app.session.execute("photo.virtualCopy", &json!({})).unwrap();+    let copy = copy["ids"][0].as_u64().or_else(|| copy["id"].as_u64()).expect("the virtual copy's id");+    let r = ask(&mut h, vec![v[0].0, copy, v[1].0]);+    assert_eq!(r["result"]["paths"], json!(["/pics/a.jpg", "/pics/b.jpg"]), "{r}");+    assert_eq!(h.app.copied.as_deref(), Some("/pics/a.jpg\n/pics/b.jpg"));+    assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Copied 2 file paths"));+}diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs--- a/crates/ui-egui/src/tests_panels.rs+++ b/crates/ui-egui/src/tests_panels.rs@@ -1736,6 +1736,13 @@ fn show_in_folders_reveals_the_photo_asked_for_and_follows_to_its_folder() {     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 mistyped id is refused (read loosely, it would quietly mean the active photo and+    // succeed); an unknown one says so+    let e = h.app.run("app.showInFolders", json!({"id": a.0.to_string()})).unwrap_err();+    assert!(e.contains("must be a photo id"), "{e}");+    let e = h.app.run("app.showInFolders", json!({"id": u64::MAX})).unwrap_err();+    assert!(e.contains("no photo"), "{e}");+    assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home"), "still the folder asked for"); }  /// A folder deeper than the tree goes has no row: the entry is disabled rather than failing.