LR-IMP-DRAGDROP: show what a file drop will do and open the import review

While files are dragged over the window a veil and a card say what dropping
does (review N items, import N presets, open the face model; close the open
dialog first when one is open). Dropped photos and folders open the import
review like File > Import; Settings > Import can import them at once. A drop
while a dialog is open is refused with a toast. The web build now reports
unreadable and failed drops. Adds the control request ui.dropFiles.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-17 19:15:44 -04:00
parent 31ee2643cf
commit 266f623480
37 changed files with 543 additions and 53 deletions
+6 -3
View File
@@ -514,7 +514,7 @@ impl WebApp {
let name = f.path().to_string_lossy().to_string();
match f.bytes_async().await {
Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await,
Err(e) => log::warn!("reading dropped {name}: {e}"),
Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())),
}
});
}
@@ -522,8 +522,11 @@ impl WebApp {
if !paths.is_empty() {
let n = paths.len();
match self.app.run("library.import", json!({"paths": paths})) {
Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })),
Err(e) => log::warn!("import: {e}"),
Ok(_) => self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_photos_added(n)),
Err(e) => {
log::warn!("import: {e}");
self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_import_failed(&e));
}
}
}
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.",
"Drop to review {n} item{} for import": "Pusťte pro kontrolu před importem (položek: {n}){:.0}",
"Drop to import {n} item{}": "Pusťte pro import (položek: {n}){:.0}",
"Drop to import {n} preset{}": "Pusťte pro import přednastavení: {n}{:.0}",
"Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}",
"Added {n} photo{}": "Přidáno fotografií: {n}{:.0}"
}
+7 -1
View File
@@ -2394,5 +2394,11 @@
"End Slideshow": "Ukončit prezentaci",
"Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…",
"Manage Effect Presets…": "Spravovat přednastavení efektů…",
"Export Effect Presets…": "Exportovat přednastavení efektů…"
"Export Effect Presets…": "Exportovat přednastavení efektů…",
"Close the open dialog, then drop the files again": "Zavřete otevřené okno, pak soubory pusťte znovu",
"Close the open dialog to drop files here": "Zavřete otevřené okno, aby sem šlo soubory pustit",
"Drop to open the face model": "Pusťte pro otevření modelu obličejů",
"Drop to import": "Pusťte pro import",
"Dropped Files": "Přetažené soubory",
"Import dropped files without review": "Importovat přetažené soubory bez kontroly"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Drop to review {n} item{} for import": "Ablegen, um vor dem Import zu prüfen (Objekte: {n}){:.0}",
"Drop to import {n} item{}": "Ablegen zum Importieren (Objekte: {n}){:.0}",
"Drop to import {n} preset{}": "Ablegen zum Importieren von Vorgaben: {n}{:.0}",
"Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}",
"Added {n} photo{}": "{n} Foto{} hinzugefügt"
}
+7 -1
View File
@@ -3422,5 +3422,11 @@
"favourites across restarts (issue #328).": "über Neustarts hinweg Favoriten (Issue #328).",
"Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-",
"inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.",
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference)."
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).",
"Close the open dialog, then drop the files again": "Schließe den geöffneten Dialog und lege die Dateien dann erneut ab",
"Close the open dialog to drop files here": "Schließe den geöffneten Dialog, um hier Dateien abzulegen",
"Drop to open the face model": "Ablegen, um das Gesichtsmodell zu öffnen",
"Drop to import": "Ablegen zum Importieren",
"Dropped Files": "Abgelegte Dateien",
"Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.",
"Drop to review {n} item{} for import": "Suelta para revisar {n} elemento{} antes de importar",
"Drop to import {n} item{}": "Suelta para importar {n} elemento{}",
"Drop to import {n} preset{}": "Suelta para importar {n} ajuste{0} preestablecido{0}",
"Could not read {name}: {e}": "No se pudo leer {name}: {e}",
"Added {n} photo{}": "{n} foto{0} añadida{0}"
}
+7 -1
View File
@@ -2342,5 +2342,11 @@
"Checking the GPU…": "Comprobando la GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.",
"Checking the GPU for faces": "Comprobando la GPU para las caras"
"Checking the GPU for faces": "Comprobando la GPU para las caras",
"Close the open dialog, then drop the files again": "Cierra el cuadro de diálogo abierto y vuelve a soltar los archivos",
"Close the open dialog to drop files here": "Cierra el cuadro de diálogo abierto para soltar archivos aquí",
"Drop to open the face model": "Suelta para abrir el modelo de caras",
"Drop to import": "Suelta para importar",
"Dropped Files": "Archivos soltados",
"Import dropped files without review": "Importar los archivos soltados sin revisión"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place.",
"Drop to review {n} item{} for import": "Déposez pour vérifier {n} élément{} avant l'import",
"Drop to import {n} item{}": "Déposez pour importer {n} élément{}",
"Drop to import {n} preset{}": "Déposez pour importer {n} préréglage{}",
"Could not read {name}: {e}": "Impossible de lire {name} : {e}",
"Added {n} photo{}": "{n} photo{} ajoutée(s)"
}
+7 -1
View File
@@ -2340,5 +2340,11 @@
"Checking the GPU…": "Vérification du GPU…",
"CPU": "Processeur",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
"Checking the GPU for faces": "Vérification du GPU pour les visages"
"Checking the GPU for faces": "Vérification du GPU pour les visages",
"Close the open dialog, then drop the files again": "Fermez la boîte de dialogue ouverte, puis déposez à nouveau les fichiers",
"Close the open dialog to drop files here": "Fermez la boîte de dialogue ouverte pour déposer des fichiers ici",
"Drop to open the face model": "Déposez pour ouvrir le modèle de visages",
"Drop to import": "Déposez pour importer",
"Dropped Files": "Fichiers déposés",
"Import dropped files without review": "Importer les fichiers déposés sans vérification"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.",
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}"
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}",
"Drop to review {n} item{} for import": "Rilascia per rivedere prima dell'importazione (elementi: {n}){:.0}",
"Drop to import {n} item{}": "Rilascia per importare (elementi: {n}){:.0}",
"Drop to import {n} preset{}": "Rilascia per importare predefiniti: {n}{:.0}",
"Could not read {name}: {e}": "Impossibile leggere {name}: {e}",
"Added {n} photo{}": "Foto aggiunte: {n}{:.0}"
}
+7 -1
View File
@@ -2346,5 +2346,11 @@
"Use Defaults": "Usa predefiniti",
"Dimensions": "Dimensioni",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.",
"Close the open dialog, then drop the files again": "Chiudi la finestra di dialogo aperta, poi rilascia di nuovo i file",
"Close the open dialog to drop files here": "Chiudi la finestra di dialogo aperta per rilasciare qui i file",
"Drop to open the face model": "Rilascia per aprire il modello dei volti",
"Drop to import": "Rilascia per importare",
"Dropped Files": "File rilasciati",
"Import dropped files without review": "Importa i file rilasciati senza revisione"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
"Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})",
"Drop to review {n} item{} for import": "ドロップして {n} 件を確認してから読み込み{:.0}",
"Drop to import {n} item{}": "ドロップして {n} 件を読み込み{:.0}",
"Drop to import {n} preset{}": "ドロップして {n} 件のプリセットを読み込み{:.0}",
"Could not read {name}: {e}": "{name} を読み取れませんでした: {e}",
"Added {n} photo{}": "{n}枚を追加しました{:.0}"
}
+7 -1
View File
@@ -2342,5 +2342,11 @@
"Checking the GPU…": "GPU を確認中…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。",
"Checking the GPU for faces": "顔用の GPU を確認中"
"Checking the GPU for faces": "顔用の GPU を確認中",
"Close the open dialog, then drop the files again": "開いているダイアログを閉じてから、もう一度ファイルをドロップしてください",
"Close the open dialog to drop files here": "ファイルをここにドロップするには、開いているダイアログを閉じてください",
"Drop to open the face model": "ドロップして顔モデルを開く",
"Drop to import": "ドロップして読み込み",
"Dropped Files": "ドロップしたファイル",
"Import dropped files without review": "ドロップしたファイルを確認せずに読み込む"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"Drop to review {n} item{} for import": "ჩააგდეთ იმპორტამდე გადასახედად (ელემენტები: {n}){:.0}",
"Drop to import {n} item{}": "ჩააგდეთ იმპორტისთვის (ელემენტები: {n}){:.0}",
"Drop to import {n} preset{}": "ჩააგდეთ პრესეტების იმპორტისთვის: {n}{:.0}",
"Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}",
"Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}"
}
+7 -1
View File
@@ -2719,5 +2719,11 @@
"≥ at least": "≥ არანაკლებ",
"⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"Close the open dialog, then drop the files again": "დახურეთ გახსნილი დიალოგი და ფაილები ხელახლა ჩააგდეთ",
"Close the open dialog to drop files here": "ფაილების აქ ჩასაგდებად დახურეთ გახსნილი დიალოგი",
"Drop to open the face model": "ჩააგდეთ სახეების მოდელის გასახსნელად",
"Drop to import": "ჩააგდეთ იმპორტისთვის",
"Dropped Files": "ჩაგდებული ფაილები",
"Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.",
"Drop to review {n} item{} for import": "Upuść, aby przejrzeć przed importem (elementy: {n}){:.0}",
"Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}",
"Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}",
"Could not read {name}: {e}": "Nie można odczytać {name}: {e}",
"Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}"
}
+7 -1
View File
@@ -2269,5 +2269,11 @@
"SDR Shadows": "Cienie SDR",
"SDR Whites": "Biele SDR",
"SDR Clarity": "Przejrzystość SDR",
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU"
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU",
"Close the open dialog, then drop the files again": "Zamknij otwarte okno dialogowe, a potem upuść pliki ponownie",
"Close the open dialog to drop files here": "Zamknij otwarte okno dialogowe, aby upuścić tu pliki",
"Drop to open the face model": "Upuść, aby otworzyć model twarzy",
"Drop to import": "Upuść, aby zaimportować",
"Dropped Files": "Upuszczone pliki",
"Import dropped files without review": "Importuj upuszczone pliki bez przeglądu"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
"Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})",
"Drop to review {n} item{} for import": "Solte para revisar antes de importar (itens: {n}){:.0}",
"Drop to import {n} item{}": "Solte para importar (itens: {n}){:.0}",
"Drop to import {n} preset{}": "Solte para importar predefinições: {n}{:.0}",
"Could not read {name}: {e}": "Não foi possível ler {name}: {e}",
"Added {n} photo{}": "{n} foto{0} adicionada{0}"
}
+7 -1
View File
@@ -2188,5 +2188,11 @@
"Color Overlay on B&W": "Sobreposição de cor em P&B",
"Image on Black": "Imagem sobre preto",
"Image on White": "Imagem sobre branco",
"White on Black": "Branco sobre preto"
"White on Black": "Branco sobre preto",
"Close the open dialog, then drop the files again": "Feche a caixa de diálogo aberta e solte os arquivos novamente",
"Close the open dialog to drop files here": "Feche a caixa de diálogo aberta para soltar arquivos aqui",
"Drop to open the face model": "Solte para abrir o modelo de rostos",
"Drop to import": "Solte para importar",
"Dropped Files": "Arquivos soltos",
"Import dropped files without review": "Importar arquivos soltos sem revisão"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.",
"Drop to review {n} item{} for import": "Отпустите для проверки перед импортом (элементов: {n}){:.0}",
"Drop to import {n} item{}": "Отпустите для импорта (элементов: {n}){:.0}",
"Drop to import {n} preset{}": "Отпустите для импорта пресетов: {n}{:.0}",
"Could not read {name}: {e}": "Не удалось прочитать {name}: {e}",
"Added {n} photo{}": "Добавлено фото: {n}{:.0}"
}
+7 -1
View File
@@ -2335,5 +2335,11 @@
"Checking the GPU…": "Проверка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.",
"Checking the GPU for faces": "Проверка ГП для лиц"
"Checking the GPU for faces": "Проверка ГП для лиц",
"Close the open dialog, then drop the files again": "Закройте открытое окно и перетащите файлы ещё раз",
"Close the open dialog to drop files here": "Закройте открытое окно, чтобы перетащить сюда файлы",
"Drop to open the face model": "Отпустите, чтобы открыть модель лиц",
"Drop to import": "Отпустите для импорта",
"Dropped Files": "Перетащенные файлы",
"Import dropped files without review": "Импортировать перетащенные файлы без проверки"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.",
"Drop to review {n} item{} for import": "Відпустіть для перегляду перед імпортом (елементів: {n}){:.0}",
"Drop to import {n} item{}": "Відпустіть для імпорту (елементів: {n}){:.0}",
"Drop to import {n} preset{}": "Відпустіть для імпорту наборів налаштувань: {n}{:.0}",
"Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}",
"Added {n} photo{}": "Додано фото: {n}{:.0}"
}
+7 -1
View File
@@ -2340,5 +2340,11 @@
"Checking the GPU…": "Перевірка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.",
"Checking the GPU for faces": "Перевірка ГП для облич"
"Checking the GPU for faces": "Перевірка ГП для облич",
"Close the open dialog, then drop the files again": "Закрийте відкрите вікно та перетягніть файли ще раз",
"Close the open dialog to drop files here": "Закрийте відкрите вікно, щоб перетягнути сюди файли",
"Drop to open the face model": "Відпустіть, щоб відкрити модель облич",
"Drop to import": "Відпустіть для імпорту",
"Dropped Files": "Перетягнуті файли",
"Import dropped files without review": "Імпортувати перетягнуті файли без перегляду"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存",
"Listing folders — folders: {folders}, files: {files}": "正在列出文件夹 — 文件夹:{folders},文件:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})",
"Drop to review {n} item{} for import": "松开鼠标以在导入前检查 {n} 个项目{:.0}",
"Drop to import {n} item{}": "松开鼠标以导入 {n} 个项目{:.0}",
"Drop to import {n} preset{}": "松开鼠标以导入 {n} 个预设{:.0}",
"Could not read {name}: {e}": "无法读取 {name}:{e}",
"Added {n} photo{}": "已添加 {n} 张照片{:.0}"
}
+7 -1
View File
@@ -2186,5 +2186,11 @@
"Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。",
"This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。",
"Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。",
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)"
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)",
"Close the open dialog, then drop the files again": "请先关闭打开的对话框,然后重新拖入文件",
"Close the open dialog to drop files here": "请先关闭打开的对话框,才能在此拖入文件",
"Drop to open the face model": "松开鼠标以打开人脸模型",
"Drop to import": "松开鼠标以导入",
"Dropped Files": "拖入的文件",
"Import dropped files without review": "不经检查直接导入拖入的文件"
}
+6 -1
View File
@@ -297,5 +297,10 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
"Listing folders — folders: {folders}, files: {files}": "正在列出資料夾 — 資料夾:{folders},檔案:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})",
"Drop to review {n} item{} for import": "放開滑鼠以在匯入前檢查 {n} 個項目{:.0}",
"Drop to import {n} item{}": "放開滑鼠以匯入 {n} 個項目{:.0}",
"Drop to import {n} preset{}": "放開滑鼠以匯入 {n} 個預設集{:.0}",
"Could not read {name}: {e}": "無法讀取 {name}:{e}",
"Added {n} photo{}": "已加入 {n} 張照片{:.0}"
}
+7 -1
View File
@@ -2143,5 +2143,11 @@
"Checking the GPU…": "正在檢查 GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。",
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU"
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU",
"Close the open dialog, then drop the files again": "請先關閉開啟的對話框,然後重新拖放檔案",
"Close the open dialog to drop files here": "請先關閉開啟的對話框,才能在此拖放檔案",
"Drop to open the face model": "放開滑鼠以開啟人臉模型",
"Drop to import": "放開滑鼠以匯入",
"Dropped Files": "拖放的檔案",
"Import dropped files without review": "不經檢查直接匯入拖放的檔案"
}
+16
View File
@@ -296,6 +296,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
ctx.request_repaint();
ok(Value::Null)
}
"ui.dropFiles" => {
let Some(paths) =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>())
else {
return err("ui.dropFiles: paths must be an array of strings");
};
if p.get("hover").and_then(Value::as_bool).unwrap_or(false) {
app.drop_hover = paths;
} else {
// the drag ends with the drop, as it does for the OS
app.drop_hover.clear();
app.drop_pending = paths;
}
ctx.request_repaint();
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
+87
View File
@@ -0,0 +1,87 @@
//! What dropping the files dragged over the window will do: the same sorting the drop applies,
//! shown on a veil over the whole window while the drag lasts.
use egui::{Align2, Color32, Context, Order, Rect, Stroke, StrokeKind, vec2};
use crate::LightcraftApp;
use crate::theme::Tokens;
/// Dropped paths by what becomes of them.
#[derive(Default, Debug, PartialEq)]
pub struct Sorted {
/// Face models (`.onnx`): the first opens the licence dialog.
pub models: Vec<String>,
/// Preset files: imported as presets.
pub presets: Vec<String>,
/// Everything else: photos and folders, for the import.
pub photos: Vec<String>,
}
/// Sort `paths` as a drop does.
pub fn classify(paths: Vec<String>) -> Sorted {
let (models, rest): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| crate::is_model_file(p));
let (presets, photos) = crate::split_presets(rest);
Sorted { models, presets, photos }
}
/// The lines the card shows for files hovering over the window (`unknown`: how many have no path
/// to look at, as on some platforms and in browsers), for the current settings.
fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String> {
if app.ui.dialog.is_some() {
return vec![crate::i18n::tr("Close the open dialog to drop files here").to_string()];
}
let drop = classify(paths);
let mut out = Vec::new();
if !drop.models.is_empty() {
out.push(crate::i18n::tr("Drop to open the face model").to_string());
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
if !drop.presets.is_empty() {
let n = drop.presets.len();
out.push(crate::i18n::tr_format!("Drop to import {n} preset{}", plural(n), n = n));
}
if !drop.photos.is_empty() {
let n = drop.photos.len();
// the web build has no review to open: its drops import at once
if app.ui.settings.import_dropped_directly || cfg!(target_arch = "wasm32") {
out.push(crate::i18n::tr_format!("Drop to import {n} item{}", plural(n), n = n));
} else {
out.push(crate::i18n::tr_format!("Drop to review {n} item{} for import", plural(n), n = n));
}
}
if out.is_empty() || unknown {
out.push(crate::i18n::tr("Drop to import").to_string());
}
out
}
/// Veil and card while files are dragged over the window.
pub fn show(app: &LightcraftApp, ctx: &Context) {
let count = ctx.input(|i| i.raw.hovered_files.len());
if count == 0 {
return;
}
let known: Vec<String> = ctx.input(|i| {
i.raw.hovered_files.iter().filter_map(|f| f.path.as_ref()).map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect()
});
let lines = lines(app, known.clone(), known.len() < count);
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::new("drop-overlay")));
painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150));
painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect();
let (w, h) = galleys.iter().fold((0.0f32, 0.0f32), |(w, h), g| (w.max(g.size().x), h + g.size().y + 8.0));
let card = Rect::from_center_size(screen.center(), vec2(w + 64.0, h + 40.0));
painter.rect_filled(card, 10.0, t.chrome);
painter.rect_stroke(card, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
let mut y = card.top() + 24.0;
for g in galleys {
let at = egui::pos2(card.center().x, y);
let height = g.size().y;
painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text);
y += height + 8.0;
}
crate::widgets::register(ctx, "dropOverlay", card);
ctx.request_repaint();
}
+15
View File
@@ -265,6 +265,21 @@ include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
/// The web build's notice for a dropped file the browser could not read.
pub fn dropped_file_unreadable(name: &str, error: &str) -> String {
tr_format!("Could not read {name}: {e}", name = name, e = error)
}
/// The web build's notice for dropped files the library refused.
pub fn dropped_import_failed(error: &str) -> String {
tr_format!("Import failed: {e}", e = error)
}
/// The web build's notice for `n` dropped photos that joined the library.
pub fn dropped_photos_added(n: usize) -> String {
tr_format!("Added {n} photo{}", if n == 1 { "" } else { "s" }, n = n)
}
/// The title and text of the message box shown when the desktop app's window can't start
/// (issue #260), in the UI language: the error and, when there is one, the log file to look in.
pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) {
+81 -21
View File
@@ -15,6 +15,7 @@ pub mod control;
pub mod credits;
pub mod date_picker;
pub mod docking;
mod drop_overlay;
pub mod export_task;
mod focus_scan;
pub mod headless;
@@ -83,6 +84,8 @@ mod tests_dialog_primary;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_drop_import;
#[cfg(test)]
mod tests_edit_layout;
#[cfg(test)]
mod tests_empty_library;
@@ -381,6 +384,11 @@ pub struct LightcraftApp {
shadow: Option<headless::HeadlessView>,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
/// Files the control channel (`ui.dropFiles`) holds over the window: every frame reports them
/// as hovered, as the OS does while a drag is over the window.
drop_hover: Vec<String>,
/// Files the control channel dropped: delivered as `dropped_files` on the next frame.
drop_pending: Vec<String>,
/// The text the UI last put on the clipboard (`ui.inspect` → `copied`).
pub copied: Option<String>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
@@ -512,6 +520,8 @@ impl LightcraftApp {
screenshot_token: 0,
shadow: None,
synthetic: vec![],
drop_hover: vec![],
drop_pending: vec![],
copied: None,
pending_picks: vec![],
synthetic_mods: egui::Modifiers::NONE,
@@ -1233,29 +1243,47 @@ impl LightcraftApp {
shortcuts::handle(self, ctx);
}
#[cfg(not(target_arch = "wasm32"))]
self.handle_dropped_files(ctx);
}
/// Files dropped on the window: face models open their licence dialog, presets import as
/// presets, photos and folders go to the import review (or straight in, Settings ▸ Import).
#[cfg(not(target_arch = "wasm32"))]
fn handle_dropped_files(&mut self, ctx: &egui::Context) {
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
if dropped.is_empty() {
return;
}
// a dialog is on screen: the review would replace it, losing what it holds
if self.ui.dialog.is_some() {
self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again"));
return;
}
let drop = drop_overlay::classify(dropped);
if let Some(model) = drop.models.first()
&& let Err(e) = panels::faces::open_dialog(self, model)
{
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
// preset files import as presets, everything else as photos
// a dropped face model opens its licence dialog; presets and photos as before
let (models, dropped): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_model_file(p));
if let Some(model) = models.first()
&& let Err(e) = panels::faces::open_dialog(self, model)
{
self.toast(ctx, e);
}
let (presets, photos) = split_presets(dropped);
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
self.toast(ctx, e);
}
if !drop.presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets}));
}
if drop.photos.is_empty() {
return;
}
if self.lightroom.is_some() {
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
return;
}
let started = if self.ui.settings.import_dropped_directly {
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
if !photos.is_empty() {
if self.lightroom.is_some() {
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
} else if let Err(e) = import::start_paths(self, photos) {
self.toast(ctx, e);
}
}
import::start_paths(self, drop.photos).map(|_| ())
} else {
import::open(self, drop.photos).map(|_| ())
};
if let Err(e) = started {
self.toast(ctx, e);
}
}
@@ -1348,6 +1376,16 @@ impl LightcraftApp {
self.synthetic_mods = egui::Modifiers::NONE;
raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
}
let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p));
if !self.drop_pending.is_empty() {
raw.dropped_files = std::mem::take(&mut self.drop_pending)
.into_iter()
.map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle)
.collect();
}
if !self.drop_hover.is_empty() {
raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect();
}
if self.synthetic.is_empty() {
return;
}
@@ -1465,6 +1503,7 @@ impl LightcraftApp {
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.end_frame(&ctx, t0);
return;
@@ -1492,6 +1531,7 @@ impl LightcraftApp {
panels::left::section_drag_feedback(self, &ctx);
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.text_focus = ctx.egui_wants_keyboard_input();
self.end_frame(&ctx, t0);
@@ -1563,6 +1603,26 @@ fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) ->
}
}
/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`.
#[derive(Debug)]
struct InjectedDrop(std::path::PathBuf);
impl egui::DroppedFile for InjectedDrop {
fn path(&self) -> &std::path::Path {
&self.0
}
#[cfg(not(target_arch = "wasm32"))]
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
fn bytes_async(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, String>> + '_>> {
Box::pin(async { Err("the web build has no files to read".to_string()) })
}
}
/// A dropped face model file (`.onnx`).
pub fn is_model_file(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
+2
View File
@@ -366,6 +366,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
if !cfg!(target_arch = "wasm32") {
heading(ui, t, crate::i18n::tr("Dropped Files"));
check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review");
heading(ui, t, crate::i18n::tr("Cameras and Cards"));
let mut on_card = d.show_on_card;
if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") {
+3
View File
@@ -415,6 +415,8 @@ pub struct AppSettings {
/// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask
/// again" turns it off).
pub ask_before_move: bool,
/// Photos and folders dropped on the window import at once, without the import review.
pub import_dropped_directly: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
@@ -473,6 +475,7 @@ impl Default for AppSettings {
startup_view: StartupView::Last,
confirm_delete: true,
ask_before_move: true,
import_dropped_directly: false,
gpu: true,
preview_limit: 0,
external_editor: String::new(),
+148
View File
@@ -0,0 +1,148 @@
//! Files dragged from the file manager onto the window: while they hover, the window says what
//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import
//! says to import dropped files directly); a drop while a dialog is open is refused.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::Dialog;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// A scratch folder that goes away with the test, however it ends.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("lc-ui-drop-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().to_string()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_png(path: &str, seed: u8) {
let (w, h) = (24usize, 16usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
/// An empty library, a folder `trip` with two photos, a window big enough for the overlay.
fn app(dir: &Scratch) -> Headless {
write_png(&dir.path("trip/a.png"), 1);
write_png(&dir.path("trip/b.png"), 2);
let session = lightcraft_engine::Session::new().with_fs();
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0);
for _ in 0..4 {
h.step();
}
h
}
fn drop_files(h: &mut Headless, paths: &[String], hover: bool) {
let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..3 {
h.step();
}
}
fn overlay(h: &Headless) -> bool {
h.app.widgets.iter().any(|(w, _)| w == "dropOverlay")
}
fn said(h: &Headless, word: &str) -> bool {
h.painted_text().iter().any(|t| t.contains(word))
}
fn photos(h: &Headless) -> usize {
h.app.session.catalog.photos().count()
}
#[test]
fn hovering_photos_says_they_will_be_reviewed_and_lets_go_when_they_leave() {
let dir = Scratch::new("hover");
let mut h = app(&dir);
assert!(!overlay(&h), "nothing is dragged over the window");
drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], true);
assert!(overlay(&h), "the window shows where to drop");
assert!(said(&h, "review 2 items"), "{:?}", h.painted_text());
drop_files(&mut h, &[], true);
assert!(!overlay(&h), "the files left the window");
}
#[test]
fn hovering_presets_says_they_will_be_imported_as_presets() {
let dir = Scratch::new("hover-presets");
let mut h = app(&dir);
std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap();
drop_files(&mut h, &[dir.path("warm.lcpreset")], true);
assert!(overlay(&h));
assert!(said(&h, "1 preset"), "{:?}", h.painted_text());
assert!(!said(&h, "review"), "presets are not reviewed");
}
#[test]
fn hovering_unknown_files_says_only_that_dropping_imports() {
let dir = Scratch::new("hover-unknown");
let mut h = app(&dir);
drop_files(&mut h, &[String::new()], true);
assert!(overlay(&h));
assert!(said(&h, "Drop to import"), "{:?}", h.painted_text());
}
#[test]
fn dropping_a_folder_opens_the_import_review_and_imports_nothing_yet() {
let dir = Scratch::new("review");
let mut h = app(&dir);
drop_files(&mut h, &[dir.path("trip")], false);
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens");
let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.candidates.len(), 2);
assert_eq!(photos(&h), 0, "nothing is imported until the review is confirmed");
assert!(!overlay(&h), "the files were let go");
}
#[test]
fn dropping_imports_directly_when_settings_say_so() {
let dir = Scratch::new("direct");
let mut h = app(&dir);
h.app.ui.settings.import_dropped_directly = true;
drop_files(&mut h, &[dir.path("trip")], false);
assert!(h.step_until(SETTLE, |h| photos(h) == 2 && h.app.import.is_none()), "both photos arrive");
assert!(h.app.ui.dialog.is_none(), "no review");
}
#[test]
fn a_drop_while_a_dialog_is_open_leaves_the_dialog_and_imports_nothing() {
let dir = Scratch::new("dialog");
let mut h = app(&dir);
h.app.ui.dialog = Some(Dialog::WhatsNew);
drop_files(&mut h, &[dir.path("trip/a.png")], true);
assert!(overlay(&h), "the window still answers the hover");
assert!(said(&h, "Close the open dialog"), "{:?}", h.painted_text());
drop_files(&mut h, &[dir.path("trip")], false);
for _ in 0..30 {
h.step();
}
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the open dialog stays");
assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import started");
assert_eq!(photos(&h), 0);
assert!(h.app.ui.toast.is_some(), "the person is told why");
}
+1
View File
@@ -90,6 +90,7 @@ filmstrip is visible.
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) |
| `ui.resize` | `{width, height}` | Resize the window |
| `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell |
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
+4 -3
View File
@@ -70,7 +70,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) |
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests_drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); a drop while a dialog is open is refused with a toast; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported |
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) |
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s |
@@ -466,7 +466,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) |
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs` | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window |
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target |
@@ -475,7 +475,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another |
| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button |
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. Files dragged over the window dim it and a card says what dropping will do; a drop of photos or folders opens the same import review as File ▸ Import |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
@@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
| 2026-10-17 | minor | LR-IMP-DRAGDROP: files dragged over the window show what dropping will do, and dropped photos and folders open the import review (Settings ▸ Import can import them at once); a drop while a dialog is open is refused; the web build tells the person when a dropped file can't be read or imported. LR-BEHAV-EMPTY-STATES, LR-BEHAV-DRAGDROP: text updated; status unchanged (✅) |
+11
View File
@@ -2,6 +2,17 @@
## 2026-10-21
### Dropping files shows what will happen
- While files are dragged over the window, it dims and a card says what dropping will do: review the photos and
folders for import, import the presets, or open the face model. If a dialog is open, the card asks you to close it
first, and a drop is refused with a message instead of replacing the dialog.
- Photos and folders dropped on the window now open the same import review as File ▸ Import, so you pick add, copy or
move, the destination and a preset before anything is imported. Settings ▸ Import ▸ "Import dropped files without
review" brings back the old behaviour of importing at once.
- In the web version, a dropped file the browser can't read, or that can't be imported, now shows a message instead of
failing silently.
- For scripts: `ui.dropFiles` hovers files over the window or drops them, as the operating system does.
### Sharper resampling in HDR and panorama merges
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around