diff --git a/README.md b/README.md index 8125ae512..162134f6a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,9 @@ To undo it, remove the entries in Windows Security → App & browser control → Routes with several steps (HWP → HWPX → DOCX) are found automatically. - **Watch folders** convert every file that lands in them. - **Recent conversions** on Home, each with a thumbnail: open the result, or open it with another app. +- **A file list on Home**: turn on *Collect dropped files in a list* in General settings, and files dropped on Home (including those dragged from a browser) wait in a list instead of opening the donut. + Drag the cards or use Alt+↑ / Alt+↓ to set the order, Delete to remove one, and *Choose action* to open the donut for the list in that order. + The list empties when that conversion completes. - **Other ways in**: the right-click menu, a keyboard shortcut, the drop zone and the [`filee` command](#command-line). - Light and dark themes with your own accent colour and donut opacity, in English, Español, 日本語, 한국어, 简体中文, 繁體中文, Polski, हिन्दी, Русский, العربية (right to left), Português (Brasil), Français, नेपाली, ไทย, Türkçe, Português (Portugal), Deutsch, Italiano, Bahasa Indonesia and Tiếng Việt. diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 194850d83..bb8d7fc50 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "عمودي", "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", - "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "merge.order_hint": "استخدم الأسهم للتنقل بين البطاقات. لتغيير الترتيب، اسحب بطاقة أو استخدم Alt + ↑ / Alt + ↓. تُدمج الملفات من الأعلى إلى الأسفل. مفتاح Delete يحذف الملف المحدد.", "merge.sort_by": "ترتيب حسب", "merge.sort_name_asc": "الاسم (أ إلى ي)", "merge.sort_name_desc": "الاسم (ي إلى أ)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "تاريخ الإنشاء (الأقدم أولاً)", "merge.sort_created_desc": "تاريخ الإنشاء (الأحدث أولاً)", "merge.sort_custom": "ترتيب مخصص", - "merge.images_hint": "حدّد صورة وانقلها إلى أعلى أو أسفل. تأتي الأولى في الأعلى، أو على اليسار عند وضع الصور جنبًا إلى جنب. (‎Alt+↑ / Alt+↓‎)", + "merge.images_hint": "استخدم الأسهم للتنقل بين الصور. لتغيير الترتيب، اسحب صورة أو اضغط Alt + ↑ / Alt + ↓. تأتي الأولى في الأعلى، أو على اليسار عند وضعها جنبًا إلى جنب. يزيل Delete الصورة المحددة.", "merge.vertical": "من الأعلى إلى الأسفل", "merge.horizontal": "جنبًا إلى جنب", "merge.same_width": "العرض نفسه", @@ -1484,5 +1484,14 @@ "engines.ollama.connecting": "جارٍ الاتصال...", "engines.ollama.complete": "اكتمل!", "engines.ollama.failed": "فشل:", - "engines.ollama.error": "خطأ:" + "engines.ollama.error": "خطأ:", + "home.queue_error": "تعذر تحويل {0}. تحتفظ القائمة بالملفات.", + "home.queue_selected": "محدد", + "home.queue_remove_file": "إزالة {0}", + "home.queue_title": "الملفات المراد تحويلها", + "home.queue_convert": "اختر إجراءً", + "home.queue_clear": "مسح القائمة", + "home.queue_remove": "إزالة", + "general.collect_dropped": "جمع الملفات المسحوبة في قائمة", + "general.collect_dropped_hint": "تبقى الملفات المسحوبة أو المختارة في قائمة الصفحة الرئيسية، ويحوّلها «اختر إجراءً» معًا بالترتيب المعروض. مغلق: كل سحب يفتح القائمة الدائرية كما كان من قبل." } diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 7db8635a1..5f4b681d2 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "PDF zusammenführen", "merge.order_title": "Reihenfolge festlegen", - "merge.order_hint": "Wähle eine Datei und verschiebe sie nach oben oder unten. Dateien werden von oben nach unten zusammengeführt. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Mit den Pfeiltasten navigieren. Zum Ändern der Reihenfolge ziehen oder Alt + ↑ / Alt + ↓ verwenden. Dateien werden von oben nach unten zusammengeführt. Entf entfernt die ausgewählte Datei.", "merge.sort_by": "Sortieren nach", "merge.sort_name_asc": "Name (A bis Z)", "merge.sort_name_desc": "Name (Z bis A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "Erstelldatum (älteste zuerst)", "merge.sort_created_desc": "Erstelldatum (neueste zuerst)", "merge.sort_custom": "Eigene Reihenfolge", - "merge.images_hint": "Wähle ein Bild und verschiebe es nach oben oder unten. Das erste kommt nach oben, nebeneinander nach links. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Mit den Pfeiltasten wählst du ein Bild aus. Zum Ändern der Reihenfolge ziehst du ein Bild oder drückst Alt + ↑ / Alt + ↓. Das erste kommt nach oben, nebeneinander nach links. Entf entfernt das ausgewählte Bild.", "merge.vertical": "Untereinander", "merge.horizontal": "Nebeneinander", "merge.same_width": "Gleiche Breite", @@ -1483,5 +1483,14 @@ "engines.ollama.connecting": "Verbindung wird hergestellt...", "engines.ollama.complete": "Abgeschlossen!", "engines.ollama.failed": "Fehlgeschlagen:", - "engines.ollama.error": "Fehler:" + "engines.ollama.error": "Fehler:", + "home.queue_error": "{0} konnte nicht konvertiert werden. Die Liste behält die Dateien.", + "home.queue_selected": "ausgewählt", + "home.queue_remove_file": "{0} entfernen", + "home.queue_title": "Zu konvertierende Dateien", + "home.queue_convert": "Aktion wählen", + "home.queue_clear": "Liste leeren", + "home.queue_remove": "Entfernen", + "general.collect_dropped": "Abgelegte Dateien in einer Liste sammeln", + "general.collect_dropped_hint": "Abgelegte oder ausgewählte Dateien warten auf der Startseite in einer Liste, und Aktion wählen konvertiert sie gemeinsam in der angezeigten Reihenfolge. Aus: jedes Ablegen öffnet das Rad, wie bisher." } diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index fbb60ebc3..baf4cd0b5 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", - "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Use the arrows to move between cards. To change the order, drag a card or press Alt + ↑ / Alt + ↓. Files merge from top to bottom. Delete removes the selected file.", "merge.sort_by": "Sort by", "merge.sort_name_asc": "Name (A to Z)", "merge.sort_name_desc": "Name (Z to A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Date created (oldest first)", "merge.sort_created_desc": "Date created (newest first)", "merge.sort_custom": "Custom order", - "merge.images_hint": "Select a picture and move it up or down. The first one goes at the top, or on the left side by side. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Use the arrows to move between pictures. To change the order, drag a picture or press Alt + ↑ / Alt + ↓. The first one goes at the top, or on the left side by side. Delete removes the selected picture.", "merge.vertical": "Top to bottom", "merge.horizontal": "Side by side", "merge.same_width": "Same width", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Connecting...", "engines.ollama.complete": "Complete!", "engines.ollama.failed": "Failed:", - "engines.ollama.error": "Error:" + "engines.ollama.error": "Error:", + "home.queue_error": "Could not convert {0}. The list keeps its files.", + "home.queue_selected": "selected", + "home.queue_remove_file": "Remove {0}", + "home.queue_title": "Files to convert", + "home.queue_convert": "Choose action", + "home.queue_clear": "Clear list", + "home.queue_remove": "Remove", + "general.collect_dropped": "Collect dropped files in a list", + "general.collect_dropped_hint": "Dropped and picked files wait in a list on the Home page, and Choose action converts them together in the order shown. Off: each drop opens the donut, as before." } diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 74275fab8..c5f7b1cbc 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", - "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Usa las flechas para navegar. Para cambiar el orden, arrastra o usa Alt + ↑ / Alt + ↓. Los archivos se combinan de arriba abajo. Supr quita el archivo seleccionado.", "merge.sort_by": "Ordenar por", "merge.sort_name_asc": "Nombre (A a Z)", "merge.sort_name_desc": "Nombre (Z a A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "Creación (antiguos primero)", "merge.sort_created_desc": "Creación (recientes primero)", "merge.sort_custom": "Orden personalizado", - "merge.images_hint": "Selecciona una imagen y muévela arriba o abajo. La primera va arriba, o a la izquierda si van lado a lado. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Usa las flechas para moverte entre las imágenes. Para cambiar el orden, arrastra una imagen o pulsa Alt + ↑ / Alt + ↓. La primera va arriba, o a la izquierda si van lado a lado. Supr quita la imagen seleccionada.", "merge.vertical": "De arriba abajo", "merge.horizontal": "Lado a lado", "merge.same_width": "Mismo ancho", @@ -1485,5 +1485,14 @@ "engines.ollama.connecting": "Conectando...", "engines.ollama.complete": "¡Completado!", "engines.ollama.failed": "Error:", - "engines.ollama.error": "Error:" + "engines.ollama.error": "Error:", + "home.queue_error": "No se pudo convertir {0}. La lista conserva los archivos.", + "home.queue_selected": "seleccionado", + "home.queue_remove_file": "Quitar {0}", + "home.queue_title": "Archivos para convertir", + "home.queue_convert": "Elegir acción", + "home.queue_clear": "Vaciar lista", + "home.queue_remove": "Quitar", + "general.collect_dropped": "Reunir los archivos soltados en una lista", + "general.collect_dropped_hint": "Los archivos soltados o elegidos esperan en una lista en la pantalla Inicio, y Elegir acción los convierte juntos en el orden mostrado. Desactivado: cada suelta abre el donut, como antes." } diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index c1ac4dc1e..daaf48328 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", - "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Utilisez les flèches pour naviguer. Pour changer l'ordre, faites glisser ou utilisez Alt + ↑ / Alt + ↓. Les fichiers sont fusionnés de haut en bas. Suppr retire le fichier sélectionné.", "merge.sort_by": "Trier par", "merge.sort_name_asc": "Nom (A à Z)", "merge.sort_name_desc": "Nom (Z à A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Date de création (anciens d'abord)", "merge.sort_created_desc": "Date de création (récents d'abord)", "merge.sort_custom": "Ordre personnalisé", - "merge.images_hint": "Sélectionnez une image et déplacez-la vers le haut ou le bas. La première va en haut, ou à gauche côte à côte. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Utilisez les flèches pour passer d'une image à l'autre. Pour changer l'ordre, faites glisser une image ou appuyez sur Alt + ↑ / Alt + ↓. La première va en haut, ou à gauche côte à côte. Suppr retire l'image sélectionnée.", "merge.vertical": "De haut en bas", "merge.horizontal": "Côte à côte", "merge.same_width": "Même largeur", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Connexion en cours...", "engines.ollama.complete": "Terminé !", "engines.ollama.failed": "Échec :", - "engines.ollama.error": "Erreur :" + "engines.ollama.error": "Erreur :", + "home.queue_error": "Impossible de convertir {0}. La liste conserve les fichiers.", + "home.queue_selected": "sélectionné", + "home.queue_remove_file": "Retirer {0}", + "home.queue_title": "Fichiers à convertir", + "home.queue_convert": "Choisir une action", + "home.queue_clear": "Vider la liste", + "home.queue_remove": "Retirer", + "general.collect_dropped": "Rassembler les fichiers déposés dans une liste", + "general.collect_dropped_hint": "Les fichiers déposés ou choisis attendent dans une liste sur l'accueil, et Choisir une action les convertit ensemble, dans l'ordre affiché. Désactivé : chaque dépôt ouvre le donut, comme avant." } diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 050cae1d2..89dcbfcc4 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "खड़ा", "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", - "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "merge.order_hint": "कार्डों के बीच जाने के लिए तीर कुंजियाँ उपयोग करें। क्रम बदलने के लिए कार्ड खींचें या Alt + ↑ / Alt + ↓ दबाएं। फ़ाइलें ऊपर से नीचे जुड़ती हैं। Delete चुनी हुई फ़ाइल हटाता है।", "merge.sort_by": "क्रमबद्ध करें", "merge.sort_name_asc": "नाम (A से Z)", "merge.sort_name_desc": "नाम (Z से A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "बनाने की तिथि (पुरानी पहले)", "merge.sort_created_desc": "बनाने की तिथि (नई पहले)", "merge.sort_custom": "कस्टम क्रम", - "merge.images_hint": "तस्वीर चुनकर उसे ऊपर या नीचे ले जाएँ। पहली तस्वीर सबसे ऊपर आएगी, या साथ-साथ जोड़ने पर सबसे बाईं ओर। (Alt+↑ / Alt+↓)", + "merge.images_hint": "तीरों से तस्वीरों के बीच जाएँ। क्रम बदलने के लिए तस्वीर खींचें या Alt + ↑ / Alt + ↓ दबाएँ। पहली तस्वीर सबसे ऊपर, या साथ-साथ रखने पर सबसे बाईं ओर आती है। Delete चुनी हुई तस्वीर हटाता है।", "merge.vertical": "ऊपर से नीचे", "merge.horizontal": "साथ-साथ", "merge.same_width": "एक जैसी चौड़ाई", @@ -1485,5 +1485,14 @@ "engines.ollama.connecting": "कनेक्ट हो रहा है...", "engines.ollama.complete": "पूर्ण हुआ!", "engines.ollama.failed": "विफल:", - "engines.ollama.error": "त्रुटि:" + "engines.ollama.error": "त्रुटि:", + "home.queue_error": "{0} को बदला नहीं जा सका। सूची में फ़ाइलें रहेंगी।", + "home.queue_selected": "चुना गया", + "home.queue_remove_file": "{0} हटाएं", + "home.queue_title": "रूपांतरित करने वाली फ़ाइलें", + "home.queue_convert": "क्रिया चुनें", + "home.queue_clear": "सूची साफ़ करें", + "home.queue_remove": "हटाएं", + "general.collect_dropped": "खींची गई फ़ाइलों को एक सूची में जोड़ें", + "general.collect_dropped_hint": "खींची या चुनी गई फ़ाइलें होम की सूची में रहती हैं, और \"क्रिया चुनें\" उन्हें दिखाए गए क्रम में एक साथ बदलता है। बंद: हर ड्रॉप पहले की तरह डोनट खोलता है।" } diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 2b07601a3..549b1f2a7 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "Gabungkan PDF", "merge.order_title": "Pilih urutan penggabungan", - "merge.order_hint": "Pilih file lalu pindahkan ke atas atau ke bawah. File digabungkan dari atas ke bawah. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Gunakan panah untuk berpindah. Untuk mengubah urutan, seret kartu atau gunakan Alt + ↑ / Alt + ↓. File digabung dari atas ke bawah. Delete menghapus file yang dipilih.", "merge.sort_by": "Urutkan menurut", "merge.sort_name_asc": "Nama (A ke Z)", "merge.sort_name_desc": "Nama (Z ke A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "Tanggal dibuat (terlama dahulu)", "merge.sort_created_desc": "Tanggal dibuat (terbaru dahulu)", "merge.sort_custom": "Urutan khusus", - "merge.images_hint": "Pilih gambar lalu pindahkan ke atas atau ke bawah. Gambar pertama berada di atas, atau di sebelah kiri bila berdampingan. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Gunakan panah untuk berpindah antargambar. Untuk mengubah urutan, seret gambar atau tekan Alt + ↑ / Alt + ↓. Gambar pertama berada di atas, atau di kiri bila berdampingan. Delete menghapus gambar yang dipilih.", "merge.vertical": "Atas ke bawah (vertikal)", "merge.horizontal": "Berdampingan (horizontal)", "merge.same_width": "Samakan lebar", @@ -1483,5 +1483,14 @@ "engines.ollama.connecting": "Menghubungkan...", "engines.ollama.complete": "Selesai!", "engines.ollama.failed": "Gagal:", - "engines.ollama.error": "Kesalahan:" + "engines.ollama.error": "Kesalahan:", + "home.queue_error": "Tidak bisa mengonversi {0}. Daftar tetap menyimpan file.", + "home.queue_selected": "dipilih", + "home.queue_remove_file": "Hapus {0}", + "home.queue_title": "File untuk dikonversi", + "home.queue_convert": "Pilih tindakan", + "home.queue_clear": "Kosongkan daftar", + "home.queue_remove": "Hapus", + "general.collect_dropped": "Kumpulkan file yang dijatuhkan dalam daftar", + "general.collect_dropped_hint": "File yang dijatuhkan atau dipilih menunggu dalam daftar di halaman Beranda, dan Pilih tindakan mengonversinya bersama sesuai urutan yang ditampilkan. Mati: setiap jatuhan membuka donat, seperti sebelumnya." } diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 86b86db84..72934839b 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -420,7 +420,7 @@ "preset.flip_vertical.short": "Verticale", "preset.merge_pdf": "Unisci PDF", "merge.order_title": "Scegli l'ordine di unione", -"merge.order_hint": "Seleziona un file e spostalo su o giù. I file vengono uniti dall'alto verso il basso. (Alt+↑ / Alt+↓)", +"merge.order_hint": "Usa le frecce per navigare. Per cambiare l'ordine, trascina oppure usa Alt + ↑ / Alt + ↓. I file vengono uniti dall'alto in basso. Canc rimuove il file selezionato.", "merge.sort_by": "Ordina per", "merge.sort_name_asc": "Nome (A-Z)", "merge.sort_name_desc": "Nome (Z-A)", @@ -429,7 +429,7 @@ "merge.sort_created_asc": "Data di creazione (prima i vecchi)", "merge.sort_created_desc": "Data di creazione (prima i recenti)", "merge.sort_custom": "Ordine personalizzato", -"merge.images_hint": "Seleziona un'immagine e spostala su o giù. La prima va in alto, o a sinistra nella disposizione affiancata. (Alt+↑ / Alt+↓)", +"merge.images_hint": "Usa le frecce per spostarti tra le immagini. Per cambiare l'ordine, trascina un'immagine o premi Alt + ↑ / Alt + ↓. La prima va in alto, o a sinistra affiancata. Canc rimuove l'immagine selezionata.", "merge.vertical": "Dall'alto verso il basso", "merge.horizontal": "Affiancate", "merge.same_width": "Stessa larghezza", @@ -1482,5 +1482,14 @@ "engines.ollama.connecting": "Connessione in corso...", "engines.ollama.complete": "Completato!", "engines.ollama.failed": "Non riuscito:", -"engines.ollama.error": "Errore:" +"engines.ollama.error": "Errore:", +"home.queue_error": "Impossibile convertire {0}. L'elenco conserva i file.", +"home.queue_selected": "selezionato", +"home.queue_remove_file": "Rimuovi {0}", +"home.queue_title": "File da convertire", +"home.queue_convert": "Scegli azione", +"home.queue_clear": "Svuota elenco", +"home.queue_remove": "Rimuovi", +"general.collect_dropped": "Raccogli i file trascinati in un elenco", +"general.collect_dropped_hint": "I file trascinati o scelti restano in un elenco nella schermata Inizio, e Scegli azione li converte insieme nell'ordine mostrato. Disattivato: ogni trascinamento apre il donut, come prima." } diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 12d2249cc..e8604dd61 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", - "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "merge.order_hint": "矢印キーでカード間を移動します。順番を変えるには、カードをドラッグするか Alt + ↑ / Alt + ↓ を押します。ファイルは上から下へ結合されます。Delete で選択中のファイルを削除します。", "merge.sort_by": "並べ替え", "merge.sort_name_asc": "名前 (昇順)", "merge.sort_name_desc": "名前 (降順)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "作成日時 (古い順)", "merge.sort_created_desc": "作成日時 (新しい順)", "merge.sort_custom": "カスタム順", - "merge.images_hint": "画像を選択して上下に移動します。最初の画像が一番上(横に並べるときは一番左)になります。(Alt+↑ / Alt+↓)", + "merge.images_hint": "矢印キーで画像を移動できます。順番を変えるには画像をドラッグするか、Alt + ↑ / Alt + ↓ を押します。最初の画像が一番上(横に並べるときは一番左)になります。Delete で選択中の画像を削除します。", "merge.vertical": "縦に並べる", "merge.horizontal": "横に並べる", "merge.same_width": "幅をそろえる", @@ -1485,5 +1485,14 @@ "engines.ollama.connecting": "接続中...", "engines.ollama.complete": "完了!", "engines.ollama.failed": "失敗:", - "engines.ollama.error": "エラー:" + "engines.ollama.error": "エラー:", + "home.queue_error": "{0} を変換できませんでした。リストのファイルは残ります。", + "home.queue_selected": "選択中", + "home.queue_remove_file": "{0} を削除", + "home.queue_title": "変換するファイル", + "home.queue_convert": "操作を選択", + "home.queue_clear": "リストをクリア", + "home.queue_remove": "削除", + "general.collect_dropped": "ドロップしたファイルをリストにまとめる", + "general.collect_dropped_hint": "ドロップまたは選択したファイルはホームのリストで待ち、「操作を選択」で表示順にまとめて変換します。オフ:ドロップごとに従来どおりドーナツが開きます。" } diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 9ed04bc6e..1bc34d2ba 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "상하", "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", - "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "merge.order_hint": "화살표로 카드 사이를 옮겨 다녀요. 순서를 바꾸려면 카드를 끌거나 Alt + ↑ / Alt + ↓를 누르세요. 위에서부터 순서대로 병합돼요. Delete로 선택한 파일을 빼요.", "merge.sort_by": "정렬 기준", "merge.sort_name_asc": "이름 (오름차순)", "merge.sort_name_desc": "이름 (내림차순)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "만든 날짜 (오래된 순)", "merge.sort_created_desc": "만든 날짜 (최신 순)", "merge.sort_custom": "사용자 지정 순서", - "merge.images_hint": "사진을 골라 위아래로 옮기세요. 첫 번째 사진이 맨 위에, 나란히 붙일 때는 맨 왼쪽에 와요. (Alt+↑ / Alt+↓)", + "merge.images_hint": "화살표로 사진 사이를 옮겨 다녀요. 순서를 바꾸려면 사진을 끌거나 Alt + ↑ / Alt + ↓를 누르세요. 첫 번째 사진이 맨 위에, 나란히 붙일 때는 맨 왼쪽에 와요. Delete로 선택한 사진을 빼요.", "merge.vertical": "위아래로", "merge.horizontal": "나란히", "merge.same_width": "너비 맞추기", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "연결 중...", "engines.ollama.complete": "완료!", "engines.ollama.failed": "실패:", - "engines.ollama.error": "오류:" + "engines.ollama.error": "오류:", + "home.queue_error": "{0}을(를) 변환하지 못했어요. 목록의 파일은 그대로 남아 있어요.", + "home.queue_selected": "선택됨", + "home.queue_remove_file": "{0} 제거", + "home.queue_title": "변환할 파일", + "home.queue_convert": "작업 선택", + "home.queue_clear": "목록 지우기", + "home.queue_remove": "제거", + "general.collect_dropped": "끌어 놓은 파일을 목록에 모으기", + "general.collect_dropped_hint": "끌어 놓거나 고른 파일이 홈의 목록에 모이고, 작업 선택을 누르면 보이는 순서대로 함께 변환해요. 끄면 예전처럼 놓을 때마다 도넛이 열려요." } diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 7182971d3..2d3b48c38 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "ठाडो", "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", - "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "merge.order_hint": "कार्डहरू बीच सार्न तीर कुञ्जी प्रयोग गर्नुहोस्। क्रम बदल्न कार्ड तान्नुहोस् वा Alt + ↑ / Alt + ↓ दबाउनुहोस्। फाइलहरू माथिबाट तलसम्म मर्ज हुन्छन्। Delete ले चयन गरिएको फाइल हटाउँछ।", "merge.sort_by": "क्रमबद्ध गर्नुहोस्", "merge.sort_name_asc": "नाम (A देखि Z)", "merge.sort_name_desc": "नाम (Z देखि A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "सिर्जना मिति (पुरानो पहिले)", "merge.sort_created_desc": "सिर्जना मिति (नयाँ पहिले)", "merge.sort_custom": "आफ्नै क्रम", - "merge.images_hint": "तस्बिर छानेर माथि वा तल सार्नुहोस्। पहिलो तस्बिर सबैभन्दा माथि, वा छेउछेउमा राख्दा सबैभन्दा बायाँ आउँछ। (Alt+↑ / Alt+↓)", + "merge.images_hint": "तीर चलाएर तस्बिरहरूबीच सर्नुहोस्। क्रम बदल्न तस्बिर तान्नुहोस् वा Alt + ↑ / Alt + ↓ थिच्नुहोस्। पहिलो तस्बिर सबैभन्दा माथि, वा छेउछेउमा राख्दा सबैभन्दा बायाँ आउँछ। Delete ले छानिएको तस्बिर हटाउँछ।", "merge.vertical": "माथिदेखि तल", "merge.horizontal": "छेउछेउमा", "merge.same_width": "उही चौडाइ", @@ -1485,5 +1485,14 @@ "engines.ollama.connecting": "जडान हुँदैछ...", "engines.ollama.complete": "सम्पन्न भयो!", "engines.ollama.failed": "विफल भयो:", - "engines.ollama.error": "त्रुटि:" + "engines.ollama.error": "त्रुटि:", + "home.queue_error": "{0} रूपान्तरण गर्न सकिएन। सूचीमा फाइलहरू रहन्छन्।", + "home.queue_selected": "चयन गरिएको", + "home.queue_remove_file": "{0} हटाउनुहोस्", + "home.queue_title": "रूपान्तरण गर्नु पर्ने फाइलहरू", + "home.queue_convert": "कार्य छान्नुहोस्", + "home.queue_clear": "सूची खाली गर्नुहोस्", + "home.queue_remove": "हटाउनुहोस्", + "general.collect_dropped": "तानिएका फाइलहरू सूचीमा जम्मा गर्नुहोस्", + "general.collect_dropped_hint": "तानिएका वा चयन गरिएका फाइलहरू होमको सूचीमा बस्छन्, र \"कार्य छान्नुहोस्\" ले देखाइएको क्रममा सबै एकसाथ रूपान्तरण गर्छ। बन्द: हरेक ड्रप पहिलेजस्तै डोनट खोल्छ।" } diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index be30d555b..ba717bcbd 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Pionowo", "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", - "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Strzałkami przechodź między kartami. Aby zmienić kolejność, przeciągnij kartę lub użyj Alt + ↑ / Alt + ↓. Pliki są łączone od góry do dołu. Delete usuwa zaznaczony plik.", "merge.sort_by": "Sortuj według", "merge.sort_name_asc": "Nazwa (A do Z)", "merge.sort_name_desc": "Nazwa (Z do A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Data utworzenia (najpierw najstarsze)", "merge.sort_created_desc": "Data utworzenia (najpierw najnowsze)", "merge.sort_custom": "Własna kolejność", - "merge.images_hint": "Wybierz obraz i przesuń go w górę lub w dół. Pierwszy trafia na górę, a przy układzie obok siebie na lewo. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Strzałkami przechodź między obrazami. Aby zmienić kolejność, przeciągnij obraz lub naciśnij Alt + ↑ / Alt + ↓. Pierwszy trafia na górę, a przy układzie obok siebie na lewo. Delete usuwa zaznaczony obraz.", "merge.vertical": "Jeden pod drugim", "merge.horizontal": "Obok siebie", "merge.same_width": "Ta sama szerokość", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Łączenie...", "engines.ollama.complete": "Ukończono!", "engines.ollama.failed": "Niepowodzenie:", - "engines.ollama.error": "Błąd:" + "engines.ollama.error": "Błąd:", + "home.queue_error": "Nie udało się przekonwertować: {0}. Lista zachowuje pliki.", + "home.queue_selected": "zaznaczony", + "home.queue_remove_file": "Usuń {0}", + "home.queue_title": "Pliki do konwersji", + "home.queue_convert": "Wybierz działanie", + "home.queue_clear": "Wyczyść listę", + "home.queue_remove": "Usuń", + "general.collect_dropped": "Zbieraj upuszczone pliki na liście", + "general.collect_dropped_hint": "Upuszczone lub wybrane pliki czekają na liście na ekranie Start, a Wybierz działanie konwertuje je razem w pokazanej kolejności. Wyłączone: każde upuszczenie otwiera pierścień, jak dotąd." } diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 67f5b484e..144a491c5 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", - "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Use as setas para navegar. Para mover a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os arquivos são mesclados de cima para baixo. Delete remove o arquivo selecionado.", "merge.sort_by": "Ordenar por", "merge.sort_name_asc": "Nome (A a Z)", "merge.sort_name_desc": "Nome (Z a A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Data de criação (antigos primeiro)", "merge.sort_created_desc": "Data de criação (recentes primeiro)", "merge.sort_custom": "Ordem personalizada", - "merge.images_hint": "Selecione uma imagem e mova para cima ou para baixo. A primeira fica no topo, ou à esquerda lado a lado. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Use as setas para navegar entre as imagens. Para mudar a ordem, arraste uma imagem ou use Alt + ↑ / Alt + ↓. A primeira fica no topo, ou à esquerda lado a lado. Delete remove a imagem selecionada.", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", "merge.same_width": "Mesma largura", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Conectando...", "engines.ollama.complete": "Concluído!", "engines.ollama.failed": "Falha:", - "engines.ollama.error": "Erro:" + "engines.ollama.error": "Erro:", + "home.queue_error": "Não foi possível converter {0}. A lista mantém os arquivos.", + "home.queue_selected": "selecionado", + "home.queue_remove_file": "Remover {0}", + "home.queue_title": "Arquivos para converter", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", + "general.collect_dropped": "Juntar arquivos soltos em uma lista", + "general.collect_dropped_hint": "Os arquivos soltos ou escolhidos ficam numa lista na tela Início, e Escolher ação converte todos juntos, na ordem mostrada. Desligado: cada solta abre o donut, como antes." } diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 65980e216..dbacc8508 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", - "merge.order_hint": "Seleciona um ficheiro e move-o para cima ou para baixo. Os ficheiros são combinados pela ordem da lista, de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Use as setas para navegar. Para mudar a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os ficheiros são unidos de cima para baixo. Delete remove o ficheiro selecionado.", "merge.sort_by": "Ordenar por", "merge.sort_name_asc": "Nome (A a Z)", "merge.sort_name_desc": "Nome (Z a A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Data de criação (antigos primeiro)", "merge.sort_created_desc": "Data de criação (recentes primeiro)", "merge.sort_custom": "Ordem personalizada", - "merge.images_hint": "Seleciona uma imagem e move-a para cima ou para baixo. A primeira fica no topo ou, lado a lado, à esquerda. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Usa as setas para navegar entre as imagens. Para mudar a ordem, arrasta uma imagem ou usa Alt + ↑ / Alt + ↓. A primeira fica no topo ou, lado a lado, à esquerda. Delete remove a imagem selecionada.", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", "merge.same_width": "Mesma largura", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "A ligar...", "engines.ollama.complete": "Concluído!", "engines.ollama.failed": "Falha:", - "engines.ollama.error": "Erro:" + "engines.ollama.error": "Erro:", + "home.queue_error": "Não foi possível converter {0}. A lista mantém os ficheiros.", + "home.queue_selected": "selecionado", + "home.queue_remove_file": "Remover {0}", + "home.queue_title": "Ficheiros a converter", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", + "general.collect_dropped": "Juntar ficheiros largados numa lista", + "general.collect_dropped_hint": "Os ficheiros largados ou escolhidos ficam numa lista no ecrã Início, e Escolher ação converte-os todos juntos, pela ordem mostrada. Desligado: cada largada abre o donut, como antes." } diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index a57001a7d..ea78ff03e 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "По верт.", "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", - "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Стрелками переходите между карточками. Чтобы изменить порядок, перетащите карточку или нажмите Alt + ↑ / Alt + ↓. Файлы объединяются сверху вниз. Delete удаляет выбранный файл.", "merge.sort_by": "Сортировка", "merge.sort_name_asc": "Имя (от А до Я)", "merge.sort_name_desc": "Имя (от Я до А)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Дата создания (сначала старые)", "merge.sort_created_desc": "Дата создания (сначала новые)", "merge.sort_custom": "Свой порядок", - "merge.images_hint": "Выберите изображение и переместите его вверх или вниз. Первое окажется сверху, а при размещении рядом — слева. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Стрелками переходите между изображениями. Чтобы изменить порядок, перетащите изображение или нажмите Alt + ↑ / Alt + ↓. Первое окажется сверху, а при размещении рядом — слева. Delete удаляет выбранное изображение.", "merge.vertical": "Сверху вниз", "merge.horizontal": "Рядом", "merge.same_width": "Одинаковая ширина", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Подключение...", "engines.ollama.complete": "Готово!", "engines.ollama.failed": "Ошибка:", - "engines.ollama.error": "Ошибка:" + "engines.ollama.error": "Ошибка:", + "home.queue_error": "Не удалось конвертировать: {0}. Список сохраняет файлы.", + "home.queue_selected": "выбрано", + "home.queue_remove_file": "Удалить {0}", + "home.queue_title": "Файлы для конвертации", + "home.queue_convert": "Выбрать действие", + "home.queue_clear": "Очистить список", + "home.queue_remove": "Удалить", + "general.collect_dropped": "Собирать перетащенные файлы в список", + "general.collect_dropped_hint": "Перетащенные или выбранные файлы ждут в списке на главной, и «Выбрать действие» конвертирует их вместе в показанном порядке. Выключено: каждое перетаскивание открывает круговое меню, как раньше." } diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 1e6be66e1..fbae21637 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "แนวตั้ง", "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", - "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "merge.order_hint": "ใช้ปุ่มลูกศรเพื่อไปยังการ์ดต่าง ๆ เปลี่ยนลำดับโดยลากการ์ดหรือกด Alt + ↑ / Alt + ↓ ไฟล์จะถูกรวมจากบนลงล่าง กด Delete เพื่อนำไฟล์ที่เลือกออก", "merge.sort_by": "เรียงตาม", "merge.sort_name_asc": "ชื่อ (A ถึง Z)", "merge.sort_name_desc": "ชื่อ (Z ถึง A)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "วันที่สร้าง (เก่าสุดก่อน)", "merge.sort_created_desc": "วันที่สร้าง (ใหม่สุดก่อน)", "merge.sort_custom": "ลำดับที่กำหนดเอง", - "merge.images_hint": "เลือกรูปแล้วเลื่อนขึ้นหรือลง รูปแรกจะอยู่บนสุด หรืออยู่ซ้ายสุดเมื่อวางเรียงข้างกัน (Alt+↑ / Alt+↓)", + "merge.images_hint": "ใช้ลูกศรเพื่อเลื่อนระหว่างรูป เปลี่ยนลำดับได้โดยลากรูปหรือกด Alt + ↑ / Alt + ↓ รูปแรกจะอยู่บนสุด หรืออยู่ซ้ายสุดเมื่อวางเรียงข้างกัน ปุ่ม Delete ลบรูปที่เลือก", "merge.vertical": "บนลงล่าง", "merge.horizontal": "เรียงข้างกัน", "merge.same_width": "ความกว้างเท่ากัน", @@ -1485,5 +1485,14 @@ "engines.ollama.connecting": "กำลังเชื่อมต่อ...", "engines.ollama.complete": "เสร็จสิ้น!", "engines.ollama.failed": "ล้มเหลว:", - "engines.ollama.error": "ข้อผิดพลาด:" + "engines.ollama.error": "ข้อผิดพลาด:", + "home.queue_error": "แปลง {0} ไม่สำเร็จ รายการยังเก็บไฟล์ไว้", + "home.queue_selected": "เลือกอยู่", + "home.queue_remove_file": "นำ {0} ออก", + "home.queue_title": "ไฟล์ที่จะแปลง", + "home.queue_convert": "เลือกการทำงาน", + "home.queue_clear": "ล้างรายการ", + "home.queue_remove": "นำออก", + "general.collect_dropped": "รวบรวมไฟล์ที่ลากวางในรายการ", + "general.collect_dropped_hint": "ไฟล์ที่ลากวางหรือเลือกจะรออยู่ในรายการหน้าแรก และ \"เลือกการทำงาน\" จะแปลงทั้งหมดพร้อมกันตามลำดับที่แสดง ปิดอยู่: ทุกครั้งที่ลากวางจะเปิดโดนัทเหมือนเดิม" } diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 346640b7d..a78466ece 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -421,7 +421,7 @@ "preset.flip_vertical.short": "Dikey", "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", - "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Kartlar arasında gezinmek için ok tuşlarını kullanın. Sırayı değiştirmek için bir kartı sürükleyin veya Alt + ↑ / Alt + ↓ kullanın. Dosyalar yukarıdan aşağıya birleştirilir. Delete seçili dosyayı kaldırır.", "merge.sort_by": "Sıralama", "merge.sort_name_asc": "Ad (A'dan Z'ye)", "merge.sort_name_desc": "Ad (Z'den A'ya)", @@ -430,7 +430,7 @@ "merge.sort_created_asc": "Oluşturma tarihi (önce en eski)", "merge.sort_created_desc": "Oluşturma tarihi (önce en yeni)", "merge.sort_custom": "Özel sıralama", - "merge.images_hint": "Bir görsel seçip yukarı veya aşağı taşıyın. İlk görsel en üste, yan yana dizilince en sola gelir. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Görseller arasında geçmek için okları kullanın. Sırayı değiştirmek için bir görseli sürükleyin ya da Alt + ↑ / Alt + ↓ tuşlarına basın. İlk görsel en üste, yan yana dizilince en sola gelir. Delete seçili görseli kaldırır.", "merge.vertical": "Alt alta", "merge.horizontal": "Yan yana", "merge.same_width": "Aynı genişlik", @@ -1483,5 +1483,14 @@ "engines.ollama.connecting": "Bağlanıyor...", "engines.ollama.complete": "Tamamlandı!", "engines.ollama.failed": "Başarısız:", - "engines.ollama.error": "Hata:" + "engines.ollama.error": "Hata:", + "home.queue_error": "{0} dönüştürülemedi. Liste dosyaları tutar.", + "home.queue_selected": "seçili", + "home.queue_remove_file": "{0} dosyasını kaldır", + "home.queue_title": "Dönüştürülecek dosyalar", + "home.queue_convert": "İşlem seç", + "home.queue_clear": "Listeyi temizle", + "home.queue_remove": "Kaldır", + "general.collect_dropped": "Bırakılan dosyaları listede topla", + "general.collect_dropped_hint": "Bırakılan ya da seçilen dosyalar Başlangıç ekranındaki listede bekler; İşlem seç hepsini gösterilen sırayla dönüştürür. Kapalıyken her bırakma donut'u açar, eskisi gibi." } diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index ee618705a..9c122d2bf 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "Dọc", "preset.merge_pdf": "Ghép PDF", "merge.order_title": "Chọn thứ tự ghép", - "merge.order_hint": "Chọn một tệp và di chuyển lên hoặc xuống. Các tệp được ghép từ trên xuống dưới. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Dùng các phím mũi tên để di chuyển giữa các thẻ. Để đổi thứ tự, hãy kéo một thẻ hoặc nhấn Alt + ↑ / Alt + ↓. Các tệp được ghép từ trên xuống dưới. Phím Delete bỏ tệp đang chọn.", "merge.sort_by": "Sắp xếp theo", "merge.sort_name_asc": "Tên (A đến Z)", "merge.sort_name_desc": "Tên (Z đến A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "Ngày tạo (cũ nhất trước)", "merge.sort_created_desc": "Ngày tạo (mới nhất trước)", "merge.sort_custom": "Thứ tự tùy chỉnh", - "merge.images_hint": "Chọn một ảnh và di chuyển lên hoặc xuống. Ảnh đầu tiên nằm trên cùng, hoặc bên trái khi xếp cạnh nhau. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Dùng các phím mũi tên để di chuyển giữa các ảnh. Để đổi thứ tự, hãy kéo một ảnh hoặc nhấn Alt + ↑ / Alt + ↓. Ảnh đầu tiên nằm trên cùng, hoặc bên trái khi xếp cạnh nhau. Phím Delete bỏ ảnh đang chọn.", "merge.vertical": "Từ trên xuống dưới", "merge.horizontal": "Cạnh nhau", "merge.same_width": "Cùng chiều rộng", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "Đang kết nối...", "engines.ollama.complete": "Hoàn tất!", "engines.ollama.failed": "Thất bại:", - "engines.ollama.error": "Lỗi:" + "engines.ollama.error": "Lỗi:", + "home.queue_error": "Không thể chuyển đổi {0}. Danh sách vẫn giữ các tệp.", + "home.queue_selected": "đã chọn", + "home.queue_remove_file": "Bỏ {0}", + "home.queue_title": "Tệp cần chuyển đổi", + "home.queue_convert": "Chọn thao tác", + "home.queue_clear": "Xóa danh sách", + "home.queue_remove": "Bỏ", + "general.collect_dropped": "Gom các tệp được thả vào một danh sách", + "general.collect_dropped_hint": "Tệp được thả hoặc chọn sẽ chờ trong một danh sách ở Trang chủ, và Chọn thao tác sẽ chuyển đổi chúng cùng lúc theo thứ tự hiển thị. Tắt: mỗi lần thả sẽ mở thanh công cụ vòng tròn như trước." } diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index f06c52b05..dd02313cb 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", - "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "merge.order_hint": "使用方向键在卡片之间移动。要更改顺序,请拖动卡片或按 Alt + ↑ / Alt + ↓。文件从上到下合并。按 Delete 移除所选文件。", "merge.sort_by": "排序方式", "merge.sort_name_asc": "名称 (A 到 Z)", "merge.sort_name_desc": "名称 (Z 到 A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "创建日期 (最早优先)", "merge.sort_created_desc": "创建日期 (最新优先)", "merge.sort_custom": "自定义顺序", - "merge.images_hint": "选择图片并上下移动。第一张放在最上面(左右排列时放在最左边)。(Alt+↑ / Alt+↓)", + "merge.images_hint": "用方向键在图片之间移动。要调整顺序,可拖动图片或按 Alt + ↑ / Alt + ↓。第一张放在最上面(左右排列时放在最左边)。Delete 删除所选图片。", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", "merge.same_width": "统一宽度", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "正在连接...", "engines.ollama.complete": "完成!", "engines.ollama.failed": "失败:", - "engines.ollama.error": "错误:" + "engines.ollama.error": "错误:", + "home.queue_error": "无法转换 {0}。列表保留这些文件。", + "home.queue_selected": "已选中", + "home.queue_remove_file": "移除 {0}", + "home.queue_title": "待转换的文件", + "home.queue_convert": "选择操作", + "home.queue_clear": "清空列表", + "home.queue_remove": "移除", + "general.collect_dropped": "将拖放的文件收集到列表中", + "general.collect_dropped_hint": "拖放或选择的文件会在首页列表中等待,“选择操作”会按显示顺序一起转换。关闭时,每次拖放都像以前一样打开圆形菜单。" } diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index a7d85e453..e2e0480e8 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -431,7 +431,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", - "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "merge.order_hint": "使用方向鍵在卡片之間移動。要變更順序,請拖曳卡片或按 Alt + ↑ / Alt + ↓。檔案由上到下合併。按 Delete 移除所選檔案。", "merge.sort_by": "排序方式", "merge.sort_name_asc": "名稱 (A 到 Z)", "merge.sort_name_desc": "名稱 (Z 到 A)", @@ -440,7 +440,7 @@ "merge.sort_created_asc": "建立日期 (最舊優先)", "merge.sort_created_desc": "建立日期 (最新優先)", "merge.sort_custom": "自訂順序", - "merge.images_hint": "選擇圖片並上下移動。第一張放在最上面(左右排列時放在最左邊)。(Alt+↑ / Alt+↓)", + "merge.images_hint": "用方向鍵在圖片之間移動。要調整順序,可拖曳圖片或按 Alt + ↑ / Alt + ↓。第一張放在最上面(左右排列時放在最左邊)。Delete 刪除所選圖片。", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", "merge.same_width": "統一寬度", @@ -1505,5 +1505,14 @@ "engines.ollama.connecting": "正在連線...", "engines.ollama.complete": "完成!", "engines.ollama.failed": "失敗:", - "engines.ollama.error": "錯誤:" + "engines.ollama.error": "錯誤:", + "home.queue_error": "無法轉換 {0}。清單會保留這些檔案。", + "home.queue_selected": "已選取", + "home.queue_remove_file": "移除 {0}", + "home.queue_title": "待轉換的檔案", + "home.queue_convert": "選擇動作", + "home.queue_clear": "清空清單", + "home.queue_remove": "移除", + "general.collect_dropped": "將拖放的檔案收集到清單中", + "general.collect_dropped_hint": "拖放或選取的檔案會在首頁清單中等待,「選擇動作」會依顯示順序一起轉換。關閉時,每次拖放都像以前一樣開啟圓形選單。" } diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..b3810796a 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -70,6 +70,7 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Filee.App/Services/ConversionService.cs b/src/Filee.App/Services/ConversionService.cs index da8868ea3..8dcdcf6ad 100644 --- a/src/Filee.App/Services/ConversionService.cs +++ b/src/Filee.App/Services/ConversionService.cs @@ -167,9 +167,10 @@ public ConversionService(JobQueue queue, UserDataStore store, ILocalizer loc, IP } /// - /// Saves a browser drop's files for a tool (the crop overlay), which needs them on disk before anything else - /// happens: always into the download folder, as the tool shows and keeps working on the original. Files that can't - /// be saved are left out ( hears why). The drop is done afterwards. + /// Saves a browser drop's files for a tool (the crop overlay) or the Home page's list, which need them on disk before + /// anything else happens: always into the download folder, as the tool shows and keeps working on the original, and + /// the list's conversions save next to them. Files that can't be saved are left out ( hears + /// why). The drop is done afterwards. /// public async Task> SaveBrowserFilesAsync(WebDrop web, Action failed) { diff --git a/src/Filee.App/Services/FilePreviews.cs b/src/Filee.App/Services/FilePreviews.cs new file mode 100644 index 000000000..66a97f7f4 --- /dev/null +++ b/src/Filee.App/Services/FilePreviews.cs @@ -0,0 +1,104 @@ +// The pictures of files in the Home page's list and the merge order window. A PDF is read whole to draw its first page, so +// the pictures are drawn one at a time, a PDF above a size limit gets none, and a picture whose card is gone is cancelled. + +using Avalonia.Media.Imaging; +using Filee.Core.Formats; +using Filee.Engines.Pdf; + +namespace Filee.App.Services; + +internal static class FilePreviews +{ + /// Width and height of the picture of a PDF's first page, in pixels (the cards show it smaller). + private const int PdfSize = 260; + + /// The largest PDF whose first page is drawn: a bigger one is read whole, which costs too much memory. + public const long MaxPdfBytes = 75L * 1024 * 1024; + + /// The longest one picture may take: a file that stalls does not hold up the others. + private static readonly TimeSpan RenderLimit = TimeSpan.FromSeconds(20); + + /// One picture at a time: the pictures of a long list are drawn in turn, not all at once. + private static readonly SemaphoreSlim Gate = new(1, 1); + + /// Whether is a PDF too large for a picture of its first page. + public static bool IsTooLarge(string path, FileFormat? format) + { + if (format?.Category != FormatCategory.Pdf) + return false; + try + { + return new FileInfo(path).Length > MaxPdfBytes; + } + catch (IOException) + { + return false; + } + } + + /// + /// The picture of , drawn in turn with the others. Null when the file has none (a format + /// without a picture, or a PDF too large). A picture is cancelled with . + /// + public static async Task RenderAsync(string path, FileFormat? format, CancellationToken cancel) + { + if (format is null || IsTooLarge(path, format)) + return null; + // The wait for a turn is not counted: the limit starts when this picture is drawn. + await Gate.WaitAsync(cancel); + var released = 0; + void Release() + { + if (Interlocked.Exchange(ref released, 1) == 0) + Gate.Release(); + } + var draw = Task.Run(() => Make(path, format)); + try + { + return await draw.WaitAsync(RenderLimit, cancel); + } + catch (TimeoutException) + { + // Too slow: this file gets no picture, and the files after it are not kept waiting. The stuck draw goes on in + // the background, so the next picture can take the gate now; what it draws is dropped. + Release(); + Abandon(draw, format); + return null; + } + catch (OperationCanceledException) + { + // The card went (or the page closed) while its picture was drawn: the picture is dropped when it is ready. + Abandon(draw, format); + throw; + } + finally + { + Release(); + } + } + + /// A draw nobody waits for any more: its picture is disposed when it completes (a PDF's; images are shared). + private static void Abandon(Task draw, FileFormat format) => + _ = draw.ContinueWith(task => + { + if (task.Status == TaskStatus.RanToCompletion && Owns(format)) + task.Result?.Dispose(); + }, TaskScheduler.Default); + + /// Draws the picture now. Pictures of images are cached and shared; a PDF's picture belongs to its caller. + private static Bitmap? Make(string path, FileFormat format) + { + if (format.Category == FormatCategory.Pdf) + { + var preview = PdfPageTools.Preview(path, 0, PdfSize); + using var stream = new MemoryStream(preview.Png); + return new Bitmap(stream); + } + return format.Category == FormatCategory.Image && HistoryThumbnails.CanShow(path) + ? HistoryThumbnails.Load(path) + : null; + } + + /// Whether a picture made by is the caller's to dispose. + public static bool Owns(FileFormat? format) => format?.Category == FormatCategory.Pdf; +} diff --git a/src/Filee.App/Services/FileQueue.cs b/src/Filee.App/Services/FileQueue.cs new file mode 100644 index 000000000..4355d4afc --- /dev/null +++ b/src/Filee.App/Services/FileQueue.cs @@ -0,0 +1,45 @@ +// The files waiting on the Home page to be converted together, in the order the user put them in. Any file can join: the +// donut offers the conversions that fit the files, as it does for files dropped anywhere else. + +using Filee.Core.Platform; + +namespace Filee.App.Services; + +/// An ordered list of files, kept without duplicates. +internal sealed class FileQueue +{ + private readonly List _paths = []; + + /// The files in the order they are converted. + public IReadOnlyList Paths => _paths; + + /// Adds at the end, in order; a file already in the queue is not added twice. + public void Add(IEnumerable paths) + { + foreach (var path in paths) + if (!_paths.Contains(path, FileSystemPaths.Comparer)) + _paths.Add(path); + } + + /// Takes out. + public bool Remove(string path) + { + var index = _paths.FindIndex(p => FileSystemPaths.Comparer.Equals(p, path)); + if (index < 0) + return false; + _paths.RemoveAt(index); + return true; + } + + /// Moves the file at to position . + public void Move(int from, int to) + { + if (from == to || from < 0 || to < 0 || from >= _paths.Count || to >= _paths.Count) + return; + var path = _paths[from]; + _paths.RemoveAt(from); + _paths.Insert(to, path); + } + + public void Clear() => _paths.Clear(); +} diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 9bbc87c0b..22f6effee 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -66,6 +66,8 @@ public sealed class RadialController( private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase); private WebDrop? _web; // the browser drop the click-mode donut's files stand for, until a conversion takes it private bool _toClipboard; // the click-mode donut's files came from the clipboard: their results go back on it + private bool _keepOrder; // the click-mode donut's files come in the order given (the Home page's queue), not sorted + private Action? _onJob; // told the conversion the donut starts (the Home page's list follows it) /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -94,6 +96,8 @@ public void ShowForDrag(int x, int y) _dropped = false; _dragReachedDonut = false; _converted = false; + _keepOrder = false; + _onJob = null; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -114,8 +118,11 @@ public void ShowForDrag(int x, int y) /// of this browser drop (the drop zone, a picture on the clipboard), which the donut now owns. /// : the files came from the clipboard, and their results go back on it. /// + /// + /// The files are merged in the order given (the Home page's queue, which the user sorted), not sorted by name first. + /// public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnotherProcess = false, WebDrop? web = null, - bool toClipboard = false) + bool toClipboard = false, bool keepOrder = false, Action? onJob = null) { if (web is null) files = platform.ResolveShortcuts(files); @@ -127,6 +134,8 @@ public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnot ReleaseProgressToToast(); ReplaceWeb(web); _toClipboard = toClipboard; + _keepOrder = keepOrder; + _onJob = onJob; if (forAnotherProcess && OperatingSystem.IsMacOS()) _grace.Start(); else @@ -376,7 +385,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w { KeepKeyboard(); // for the order window Close(animate: false); - _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard); + _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard, _keepOrder); return; } @@ -386,7 +395,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w { KeepKeyboard(); // for the pages window Close(animate: false); - _ = AskPagesThenConvertAsync(sources, preset, web, _toClipboard); + _ = AskPagesThenConvertAsync(sources, preset, web, _toClipboard, _onJob); return; } @@ -396,7 +405,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w { KeepKeyboard(); // for the save dialog Close(animate: false); - _ = AskThenConvertAsync(sources, preset, _toClipboard, web); + _ = AskThenConvertAsync(sources, preset, _toClipboard, web, _onJob); return; } @@ -408,23 +417,32 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w Close(); return; } + _onJob?.Invoke(job.Job); _converted = true; ShowProgress(job); ReturnKeyboard(); // the ring shows the conversion; nothing here needs the keyboard any more } - private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard) + private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard, bool keepOrder) { + // The donut's job callback, taken now: the window may be waiting a while, and another donut may open meanwhile. + var onJob = _onJob; try { - var ordered = await windows.ChooseMergeOrderAsync(files, preset); + // The queue's order is the one the user already set on the Home page: the chooser only shows for a merge that + // has a layout to pick (merged pictures). + var ordered = keepOrder && !preset.JoinsImages + ? files + : await windows.ChooseMergeOrderAsync(files, preset, keepOrder); if (ordered is not null && preset.JoinsImages) RememberMergeLayout(preset); if (ordered is not null && preset.Output.AskEveryTime && web is null) - await AskThenConvertAsync(ordered, preset, toClipboard); + await AskThenConvertAsync(ordered, preset, toClipboard, null, onJob); else if (ordered is not null) { - conversions.Start(ordered, preset, web: web, toClipboard: toClipboard); + var job = conversions.Start(ordered, preset, web: web, toClipboard: toClipboard); + if (job is not null) + onJob?.Invoke(job.Job); web = null; // the conversion owns the browser drop now } } @@ -470,7 +488,8 @@ private void RememberMergeLayout(Preset chosen) /// job; other files dropped with them convert as the preset says. Files from a browser are saved first, as for a /// tool: the window counts the pages of the PDF itself. /// - private async Task AskPagesThenConvertAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard) + private async Task AskPagesThenConvertAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard, + Action? onJob) { try { @@ -509,9 +528,9 @@ private async Task AskPagesThenConvertAsync(IReadOnlyList files, Preset foreach (var (runFiles, runPreset) in runs) { if (runPreset.Output.AskEveryTime) - await AskThenConvertAsync(runFiles, runPreset, toClipboard); - else - conversions.Start(runFiles, runPreset, toClipboard: toClipboard); + await AskThenConvertAsync(runFiles, runPreset, toClipboard, null, onJob); + else if (conversions.Start(runFiles, runPreset, toClipboard: toClipboard) is { } job) + onJob?.Invoke(job.Job); } } catch (Exception ex) when (ex is not OutOfMemoryException) @@ -543,12 +562,16 @@ private void RememberSplitChoice(Preset preset, PageChoice chosen) } } - private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset, bool toClipboard, WebDrop? web = null) + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset, bool toClipboard, WebDrop? web = null, + Action? onJob = null) { try { - await conversions.AskThenStartAsync(sources, preset, web, + // The job starts only once the user has chosen where to save: the list follows it then, as any other job. + var job = await conversions.AskThenStartAsync(sources, preset, web, (running, one) => windows.AskSaveLocationAsync(sources, running, one), toClipboard); + if (job is not null) + onJob?.Invoke(job.Job); } catch (Exception ex) when (ex is not OutOfMemoryException) { diff --git a/src/Filee.App/Services/StagedFiles.cs b/src/Filee.App/Services/StagedFiles.cs new file mode 100644 index 000000000..84e6da78b --- /dev/null +++ b/src/Filee.App/Services/StagedFiles.cs @@ -0,0 +1,150 @@ +// The files waiting on the Home page to be converted together, in the order they are converted or merged in. It lives as +// long as the app, so the list survives a change of page or language. The list empties only when the conversion it was +// sent to completes; a conversion that fails keeps the list and names the files that failed. + +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.App.ViewModels.Pages; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Platform; + +namespace Filee.App.Services; + +public sealed partial class StagedFiles : ObservableObject, IDisposable +{ + private readonly FileQueue _queue = new(); + private readonly JobQueue _jobs; + private readonly ILocalizer _loc; + + /// The cards sent to the donut, until the conversion the donut starts takes them. + private List _sent = []; + + /// The cards of each conversion started from the donut, by job: each leaves the list only with its own job. + private readonly Dictionary> _startedJobs = []; + + public StagedFiles(JobQueue jobs, ILocalizer loc) + { + _jobs = jobs; + _loc = loc; + _jobs.JobFinished += OnJobFinished; + } + + /// The cards, in the order the files are converted or merged in. + public ObservableCollection Cards { get; } = []; + + /// The files' paths in the order they are converted. + public IReadOnlyList Paths => _queue.Paths.ToArray(); + + /// Why the last conversion of the list failed, naming its files; empty when there is nothing to report. + [ObservableProperty] + private string _error = ""; + + /// Adds the files to the end of the list. + public void Add(IEnumerable paths) + { + _queue.Add(paths); + Sync(); + } + + /// Moves a file, from the user dragging its card or the keyboard. + public void Move(int from, int to) + { + _queue.Move(from, to); + Cards.Move(from, to); + Renumber(); + } + + /// Takes a card's file out of the list. + public void Remove(StagedFileViewModel card) + { + _queue.Remove(card.Path); + Sync(); + } + + /// Empties the list. + public void Clear() + { + _queue.Clear(); + foreach (var card in Cards) + card.Dispose(); + Cards.Clear(); + Error = ""; + } + + /// The cards were sent to the donut: they leave the list when the conversion the donut starts completes. + public void MarkSent(IReadOnlyList cards) + { + _sent = cards.ToList(); + Error = ""; + } + + /// + /// A conversion started from the donut takes the cards sent to it. Cards sent while another conversion runs wait in the + /// list for their own conversion; the running one still clears only its own cards. + /// + public void ConversionStarted(ConversionJob job) + { + if (_sent.Count == 0) + return; + _startedJobs[job.Id] = _sent; + _sent = []; + } + + /// The place of each card follows the list. + private void Renumber() + { + for (var i = 0; i < Cards.Count; i++) + { + Cards[i].Position = i + 1; + Cards[i].SetSize = Cards.Count; + } + } + + /// Brings the cards in line with the list: cards of files that left it go, new files get a card at the end. + private void Sync() + { + foreach (var card in Cards.Where(card => !_queue.Paths.Contains(card.Path, FileSystemPaths.Comparer)).ToList()) + { + Cards.Remove(card); + _sent.Remove(card); + foreach (var started in _startedJobs.Values) + started.Remove(card); + card.Dispose(); + } + foreach (var path in _queue.Paths) + { + if (Cards.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path))) + continue; + var card = new StagedFileViewModel(path, FormatRegistry.Detect(path), Remove, _loc); + Cards.Add(card); + _ = card.LoadPreviewAsync(); + } + Renumber(); + } + + private void OnJobFinished(object? sender, ConversionJob job) => Dispatcher.UIThread.Post(() => Finished(job)); + + private void Finished(ConversionJob job) + { + if (!_startedJobs.Remove(job.Id, out var sent)) + return; + if (job.State == JobState.Cancelled) + return; + if (job.State == JobState.Completed) + { + foreach (var card in sent.Where(card => Cards.Contains(card))) + Remove(card); + return; + } + var failed = job.Files.Where(file => file.State == FileState.Failed).Select(file => Path.GetFileName(file.SourcePath)); + Error = _loc.Format("home.queue_error", string.Join(", ", failed)); + } + + public void Dispose() + { + _jobs.JobFinished -= OnJobFinished; + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index debc92d3e..970e9ff42 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -60,12 +60,13 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem /// /// Reviews the order of a merge (, when given, names the button). For "Merge images" the /// layout is chosen too and written into . Closing or cancelling starts no conversion. + /// : the files start in the order given (see ). /// - public async Task?> ChooseMergeOrderAsync(IReadOnlyList files, Preset? preset = null) + public async Task?> ChooseMergeOrderAsync(IReadOnlyList files, Preset? preset = null, bool keepOrder = false) { _mergeOrder?.Close(); var images = preset is { JoinsImages: true }; - var vm = new MergeOrderViewModel(files, images ? preset!.Image : null); + var vm = new MergeOrderViewModel(files, images ? preset!.Image : null, keepOrder, loc); var dialog = new MergeOrderWindow { DataContext = vm }; if (preset is not null) dialog.StartMerge.Content = loc.DisplayName(preset); @@ -88,6 +89,7 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem await closed.Task; if (dialog.Result is not null && images) vm.ApplyTo(preset!.Image); + vm.Dispose(); return dialog.Result; } diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index b4576615d..38f5bd05f 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -6,8 +6,12 @@ using System.Collections.ObjectModel; using System.Globalization; using Avalonia.Media; +using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Formats; +using Filee.Core.Localization; using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Web; @@ -15,37 +19,127 @@ namespace Filee.App.ViewModels; /// -/// One file of the merge: its name, its date and the folder it is in (no date or folder for a file from a browser, not -/// saved yet). +/// One file of the merge: its name, the folder it is in (none for a file from a browser, not saved yet), and its picture +/// (the first page of a PDF, or the picture itself), drawn in turn with the others so the window opens at once. /// -public sealed partial class MergeSource(string path) : ObservableObject +public sealed partial class MergeSource(string path, Action remove, ILocalizer? loc = null) : ObservableObject, IDisposable { + private readonly bool _oversized = FilePreviews.IsTooLarge(path, FormatRegistry.Detect(path)); + public string Path { get; } = path; public string Name => System.IO.Path.GetFileName(Path); public string Folder => WebDrop.IsPlaceholder(Path) ? "" : System.IO.Path.GetDirectoryName(Path) ?? ""; + /// The format, or null when Filee doesn't know it (the file then shows no picture). + public FileFormat? Format { get; } = FormatRegistry.Detect(path); + + /// The extension ("PDF", "PNG"), shown when the file has no picture. + public string FormatBadge => Format is null ? "" : Format.PrimaryExtension.TrimStart('.').ToUpperInvariant(); + + /// A PDF too large for its picture: the card says so where the picture goes. + public bool Oversized => _oversized; + + /// Stops the picture still being drawn when the file leaves the merge. + public CancellationTokenSource Cancellation { get; } = new(); + + /// The place in the merge, from 1: the order the files merge in. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private int _position; + + /// The card the user selected (the list's selection), for the screen reader. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private bool _isSelected; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + public bool HasThumbnail => Thumbnail is not null; + + /// What a screen reader says for the card: its place, its name, and whether it is selected. + public string AccessibleName => + $"{Position}. {Name}" + (IsSelected ? ", " + (loc?["home.queue_selected"] ?? "selected") : ""); + + /// What the card's X says for a screen reader: which file it takes out. + public string RemoveName => loc is null ? $"Remove {Name}" : loc.Format("home.queue_remove_file", Name); + + /// The card's X: takes the file out of the merge. + [RelayCommand] + private void Remove() => remove(this); + + /// Draws the picture, in turn with the others; a file that leaves the merge meanwhile gets none. + public async Task LoadPreviewAsync() + { + if (Oversized) + return; + try + { + var bitmap = await FilePreviews.RenderAsync(Path, Format, Cancellation.Token); + if (Cancellation.IsCancellationRequested) + { + if (FilePreviews.Owns(Format)) + bitmap?.Dispose(); + return; + } + Thumbnail = bitmap; + } + catch (OperationCanceledException) + { + // The file left the merge: nothing to show. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the file shows its extension instead. + } + } + + /// Cancels a picture still being drawn and disposes the one drawn (a PDF's; images are cached and shared). + public void Dispose() + { + Cancellation.Cancel(); + if (FilePreviews.Owns(Format)) + Thumbnail?.Dispose(); + Thumbnail = null; + } + /// The dates, read once: the order and the dates shown stay the same while the window is open. public FileStamp Stamp { get; } = WebDrop.IsPlaceholder(path) ? new FileStamp(path) : FileStamp.Read(path); - /// The row shows the date created rather than the date modified (while sorted by it). + /// The card shows the date created rather than the date modified (while sorted by it). [ObservableProperty] - [NotifyPropertyChangedFor(nameof(Time))] + [NotifyPropertyChangedFor(nameof(Time), nameof(Day), nameof(Tip))] private bool _showsCreated; /// The date and time shown, in the user's format. public string Time => (ShowsCreated ? Stamp.Created : Stamp.Modified)?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? ""; + + /// The date alone, for the card (the time doesn't fit under the picture). + public string Day => (ShowsCreated ? Stamp.Created : Stamp.Modified)?.ToLocalTime().ToString("d", CultureInfo.CurrentCulture) ?? ""; + + /// The card's tooltip: the whole path, then the date and time. + public string Tip => Time.Length == 0 ? Path : $"{Path}\n{Time}"; } -public sealed partial class MergeOrderViewModel : ObservableObject +public sealed partial class MergeOrderViewModel : ObservableObject, IDisposable { /// The files to merge. /// /// "Merge images" (#645): the layout the pictures are merged with last time, which the window also offers to change. /// Null for other merges. /// - public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null) + /// The files are already in the order to merge them in (the Home page's queue): not sorted by name. + public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null, bool keepOrder = false, ILocalizer? loc = null) { - Files = new(FileSorter.Sort(files.Select(file => new MergeSource(file)), file => file.Stamp, FileSortOrder.NameAscending)); + var sources = files.Select(file => new MergeSource(file, RemoveFile, loc)); + // The Home page's queue keeps the order it was given; otherwise the files start by name. + Files = new(keepOrder ? sources : FileSorter.Sort(sources, file => file.Stamp, FileSortOrder.NameAscending)); + if (keepOrder) + _sortIndex = CustomSortIndex; + Files.CollectionChanged += (_, _) => OnPropertyChanged(nameof(CanMerge)); + Renumber(); + foreach (var source in Files) + _ = source.LoadPreviewAsync(); SelectedFile = Files.FirstOrDefault(); if (images is null) return; @@ -60,6 +154,15 @@ public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = n public ObservableCollection Files { get; } + /// Merge needs at least two files: the button is off below that. + public bool CanMerge => Files.Count >= 2; + + partial void OnSelectedFileChanged(MergeSource? value) + { + foreach (var file in Files) + file.IsSelected = ReferenceEquals(file, value); + } + /// The list entry after the sort orders: the files were moved by hand. public const int CustomSortIndex = (int)FileSortOrder.CreatedDescending + 1; @@ -89,6 +192,7 @@ private void ApplySort(FileSortOrder order) SelectedFile = selected; MoveUpCommand.NotifyCanExecuteChanged(); MoveDownCommand.NotifyCanExecuteChanged(); + Renumber(); } /// The files are pictures that become one image: the window shows the layout too. @@ -168,6 +272,79 @@ public void ApplyTo(ImageOptions image) [RelayCommand(CanExecute = nameof(CanMoveDown))] private void MoveDown() => Move(1); + /// The window closed: the pictures are released. + public void Dispose() + { + foreach (var file in Files) + file.Dispose(); + } + + /// A card dragged to another place: the file moves there, and it stays selected (the merge follows the order). + internal void MoveFile(int from, int to) + { + if (from == to || from < 0 || to < 0 || from >= Files.Count || to >= Files.Count) + return; + Files.Move(from, to); + Renumber(); + // A card moved by hand is a custom order: the sort list shows that, and a later sort starts from it. + SortIndex = CustomSortIndex; + SelectedFile = Files[to]; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + /// The left and right arrows: the selection moves to the neighbour, and stays on a card after a move. + internal void SelectNeighbour(int delta) + { + if (Files.Count == 0) + return; + var index = SelectedFile is null ? -1 : Files.IndexOf(SelectedFile); + SelectedFile = Files[Math.Clamp(index < 0 ? 0 : index + delta, 0, Files.Count - 1)]; + } + + /// The left arrow: the card on its left (on the right, in a right-to-left language). + [RelayCommand] + private void SelectLeft() => SelectNeighbour(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? 1 : -1); + + /// The right arrow: the card on its right (on the left, in a right-to-left language). + [RelayCommand] + private void SelectRight() => SelectNeighbour(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? -1 : 1); + + /// The Delete key: the selected file leaves the merge. + [RelayCommand] + private void DeleteSelected() => RemoveSelected(); + + /// The Delete command's work: the selected file leaves the merge. + internal void RemoveSelected() + { + if (SelectedFile is { } selected) + RemoveFile(selected); + } + + /// The card's X: the file leaves the merge, and the selection moves to a neighbour. + internal void RemoveFile(MergeSource file) + { + var index = Files.IndexOf(file); + if (index < 0) + return; + var wasSelected = ReferenceEquals(SelectedFile, file); + Files.RemoveAt(index); + file.Dispose(); + // The list box clears its selection while the row goes, which reaches SelectedFile as null: the neighbour is + // chosen after the removal, from whether the removed card was the selected one. + if (wasSelected) + SelectedFile = Files.Count == 0 ? null : Files[Math.Min(index, Files.Count - 1)]; + Renumber(); + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + private void Renumber() + { + for (var i = 0; i < Files.Count; i++) + Files[i].Position = i + 1; + } + private void Move(int direction) { if (SelectedFile is not { } selected) @@ -177,6 +354,7 @@ private void Move(int direction) if (index < 0 || target < 0 || target >= Files.Count) return; Files.Move(index, target); + Renumber(); // The list box can drop its selection while it moves the row. SelectedFile = selected; SortIndex = CustomSortIndex; diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index c55d35401..5e3c1eed4 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -47,6 +47,7 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic _defaultSubfolder = output.SubfolderName; _defaultFolder = output.CustomFolder; _keepFileDates = store.Settings.KeepFileDates; + _collectDroppedFiles = store.Settings.CollectDroppedFiles; _browserFolder = store.Settings.BrowserDrops.Folder; _saveOnlyConverted = store.Settings.BrowserDrops.SaveOnlyConverted; _zipGeoResults = store.Settings.ZipGeoResults; @@ -92,6 +93,8 @@ partial void OnDefaultSubfolderChanged(string value) => partial void OnDefaultFolderChanged(string value) => Save(s => s.DefaultOutput.CustomFolder = value.Trim()); [ObservableProperty] private bool _keepFileDates; + + [ObservableProperty] private bool _collectDroppedFiles; [ObservableProperty] private bool _zipGeoResults; /// Where files dragged from a browser are saved; empty for the Downloads folder. @@ -131,6 +134,8 @@ private void OnSettingsChanged(object? sender, EventArgs e) partial void OnKeepFileDatesChanged(bool value) => Save(s => s.KeepFileDates = value); + partial void OnCollectDroppedFilesChanged(bool value) => Save(s => s.CollectDroppedFiles = value); + partial void OnZipGeoResultsChanged(bool value) => Save(s => s.ZipGeoResults = value); [ObservableProperty] private Choice _language; diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index e6487327d..d1655373b 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -1,6 +1,8 @@ // Home: drop zone (handled in the view) and recent conversions. using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Globalization; using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -206,13 +208,16 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; private readonly ElevationService? _elevation; + private readonly StagedFiles _staged; - public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, + public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, StagedFiles staged, + Services.Triggers.TriggerService? triggers = null, ConversionService? conversions = null, ElevationService? elevation = null) { _store = store; _loc = loc; _platform = platform; + _staged = staged; _triggers = triggers; _conversions = conversions; _elevation = elevation; @@ -223,15 +228,33 @@ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag); DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; + // The queue outlives this page (it is the service's), so these are unsubscribed in Dispose. + Queue.CollectionChanged += OnQueueChanged; + _staged.PropertyChanged += OnStagedChanged; Reload(); } - private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload); + private void OnQueueChanged(object? sender, NotifyCollectionChangedEventArgs e) => OnPropertyChanged(nameof(HasQueue)); + + private void OnStagedChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(StagedFiles.Error)) + return; + OnPropertyChanged(nameof(QueueError)); + OnPropertyChanged(nameof(HasQueueError)); + } + + private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => + { + Reload(); + }); public void Dispose() { _thumbnails?.Cancel(); _store.HistoryChanged -= OnHistoryChanged; + Queue.CollectionChanged -= OnQueueChanged; + _staged.PropertyChanged -= OnStagedChanged; if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; if (_elevation is not null) @@ -267,6 +290,97 @@ private void OpenAccessibility() public string DropHint { get; } + /// + /// The files waiting to be converted together, in the order they are converted or merged in. The list is kept by + /// for the whole session, so it survives a change of page or language. + /// + public ObservableCollection Queue => _staged.Cards; + + public bool HasQueue => Queue.Count > 0; + + /// Why the last conversion of the list failed, naming its files; empty otherwise. + public string QueueError => _staged.Error; + + public bool HasQueueError => QueueError.Length > 0; + + /// The queued files' paths in the order they are converted. + public IReadOnlyList QueuedPaths => _staged.Paths; + + /// Adds dropped or picked files to the end of the list. + public void AddToQueue(IEnumerable paths) => _staged.Add(paths); + + /// Moves a queued file, from the user dragging its card. + public void MoveQueued(int from, int to) => _staged.Move(from, to); + + /// The card's X: takes its file out of the list. + internal void RemoveFromQueue(StagedFileViewModel card) => _staged.Remove(card); + + /// A click on a card selects it (only that one); the Delete key takes the selected file out of the list. + public void SelectQueued(int index) + { + for (var i = 0; i < Queue.Count; i++) + Queue[i].IsSelected = i == index; + } + + /// The left arrow: the card on its left (on the right, in a right-to-left language). + [RelayCommand] + private void SelectLeft() => SelectNeighbour(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? 1 : -1); + + /// The right arrow: the card on its right (on the left, in a right-to-left language). + [RelayCommand] + private void SelectRight() => SelectNeighbour(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? -1 : 1); + + /// The Delete key: takes the selected file out of the list. + [RelayCommand] + private void DeleteSelected() => RemoveSelectedQueued(); + + /// The left and right arrows: the selection moves to the neighbouring card. + public void SelectNeighbour(int delta) + { + if (Queue.Count == 0) + return; + var index = Queue.ToList().FindIndex(card => card.IsSelected); + SelectQueued(Math.Clamp(index < 0 ? 0 : index + delta, 0, Queue.Count - 1)); + } + + /// Alt+Up / Alt+Down: the selected card moves one place earlier or later, and stays selected. + public void MoveSelectedQueued(int delta) + { + var index = Queue.ToList().FindIndex(card => card.IsSelected); + if (index < 0 || index + delta < 0 || index + delta >= Queue.Count) + return; + MoveQueued(index, index + delta); + } + + /// The Delete key: takes the selected file out of the list, if one is selected. + public void RemoveSelectedQueued() + { + var index = Queue.ToList().FindIndex(card => card.IsSelected); + if (index < 0) + return; + RemoveFromQueue(Queue[index]); + // The card that takes its place is selected, as in the merge window (the last one when it was the last). + if (Queue.Count > 0) + SelectQueued(Math.Min(index, Queue.Count - 1)); + } + + /// + /// The list was sent to the donut: its files leave the list when the conversion the donut starts completes. A + /// cancelled donut keeps them; a failed conversion keeps them and names the files that failed. + /// + public void MarkSentToDonut() => _staged.MarkSent([.. Queue]); + + /// The donut started a conversion: it is the one whose outcome empties the list. + internal void ConversionStarted(ConversionJob job) => _staged.ConversionStarted(job); + + /// Empties the list: the "Clear list" button. + [RelayCommand] + public void ClearQueue() => _staged.Clear(); + + /// The error notice's X: closes it. + [RelayCommand] + private void DismissQueueError() => _staged.Error = ""; + /// How many conversions the page lists: the newest, or the newest that match the search. internal const int Shown = 40; diff --git a/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs new file mode 100644 index 000000000..60a65fe82 --- /dev/null +++ b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs @@ -0,0 +1,110 @@ +// One file of the Home page's list: its number, its picture (the first page of a PDF, a picture itself) and its format. + +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels.Pages; + +public sealed partial class StagedFileViewModel : ObservableObject, IDisposable +{ + private readonly Action _remove; + private readonly ILocalizer _loc; + private readonly FileFormat? _format; + + /// The file's format, or null when Filee doesn't know it (the card then shows no picture). + public StagedFileViewModel(string path, FileFormat? format, Action remove, ILocalizer loc) + { + Path = path; + Name = System.IO.Path.GetFileName(path); + FormatBadge = System.IO.Path.GetExtension(path).TrimStart('.').ToUpperInvariant(); + _format = format; + _remove = remove; + _loc = loc; + Oversized = FilePreviews.IsTooLarge(path, format); + } + + /// The card the user selected: the Delete key takes it out of the list. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private bool _isSelected; + + public string Path { get; } + + /// The file name, shown under its picture. + public string Name { get; } + + /// The extension ("PDF", "PNG"), shown when the file has no picture. + public string FormatBadge { get; } + + /// The place in the list, from 1: the order the files are converted or merged in. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private int _position; + + /// How many cards the list has: a screen reader says the card's place out of them. + [ObservableProperty] + private int _setSize; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + public bool HasThumbnail => Thumbnail is not null; + + /// A PDF too large for its picture: the card says so where the picture goes. + public bool Oversized { get; } + + + /// Stops the picture still being drawn when the card goes. + public CancellationTokenSource Cancellation { get; } = new(); + + /// What a screen reader says for the card: its place, its name, and whether it is selected. + public string AccessibleName => + $"{Position}. {Name}" + (IsSelected ? ", " + _loc["home.queue_selected"] : ""); + + /// What the card's X says for a screen reader: which file it takes out. + public string RemoveName => _loc.Format("home.queue_remove_file", Name); + + /// Draws the picture, in turn with the others; a card that goes meanwhile gets none. + public async Task LoadPreviewAsync() + { + if (Oversized) + return; + try + { + var bitmap = await FilePreviews.RenderAsync(Path, _format, Cancellation.Token); + if (Cancellation.IsCancellationRequested) + { + if (FilePreviews.Owns(_format)) + bitmap?.Dispose(); + return; + } + Thumbnail = bitmap; + } + catch (OperationCanceledException) + { + // The card went: nothing to show. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the card shows its extension instead. The log says why, for a file that should have one. + AppHost.Get>().LogWarning(ex, "No picture for {File}", Path); + } + } + + [RelayCommand] + private void Remove() => _remove(this); + + /// Cancels a picture still being drawn and disposes the one drawn (a PDF's; images are cached and shared). + public void Dispose() + { + Cancellation.Cancel(); + if (FilePreviews.Owns(_format)) + Thumbnail?.Dispose(); + Thumbnail = null; + } +} diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml index 66e3b7a38..df8f78b3f 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml +++ b/src/Filee.App/Views/MergeOrderWindow.axaml @@ -1,6 +1,7 @@ + one image (#645) also get their layout here, below the list. The smallest size still shows a whole row of cards + under the hint, which runs longest in Arabic. --> - - - - + @@ -66,18 +65,19 @@ Background="{DynamicResource SurfaceBrush}"> - - + + + + - - + diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml.cs b/src/Filee.App/Views/MergeOrderWindow.axaml.cs index 430c21c40..9f2ead8fd 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml.cs +++ b/src/Filee.App/Views/MergeOrderWindow.axaml.cs @@ -1,5 +1,14 @@ +// The order of a merge: the cards can be dragged to another place with the mouse, and the Delete key takes the selected +// file out of the merge. The chosen order is the one the files merge in. + +using System.Globalization; +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; using Filee.App.Services; using Filee.App.ViewModels; @@ -7,11 +16,35 @@ namespace Filee.App.Views; public partial class MergeOrderWindow : Window { + /// Pixels a press may move before it counts as a drag of a card rather than a click. + private const double DragThreshold = 6; + + /// The card under a press: its index, and where the press began (in ). + private (int Index, Point Start)? _pressed; + + /// The press has moved far enough to drag its card. + private bool _dragging; + public MergeOrderWindow() { InitializeComponent(); Motion.Track(this); UiZoom.Track(this); + // The list has the keys from the start: the arrows move the selection without a click first. + Opened += (_, _) => Dispatcher.UIThread.Post(() => + { + // After the window is active: activating it would otherwise take the focus back. + Activate(); + FocusSelected(); + }, DispatcherPriority.Background); + // Tunnelled: the arrows and Alt+Up / Alt+Down are taken before the list's own navigation, which would move the + // focus to a button at the edge of the row (and drop it when a card is moved). + AddHandler(KeyDownEvent, OnKeyDownInList, RoutingStrategies.Tunnel); + // Handled too: the list selects a card on press, and the drag still has to start. + FileOrder.AddHandler(PointerPressedEvent, OnCardPressed, RoutingStrategies.Bubble, handledEventsToo: true); + FileOrder.AddHandler(PointerMovedEvent, OnCardMoved, RoutingStrategies.Bubble, handledEventsToo: true); + FileOrder.AddHandler(PointerReleasedEvent, OnCardReleased, RoutingStrategies.Bubble, handledEventsToo: true); + FileOrder.FlowDirection = IsRightToLeft ? Avalonia.Media.FlowDirection.RightToLeft : Avalonia.Media.FlowDirection.LeftToRight; } public IReadOnlyList? Result { get; private set; } @@ -24,4 +57,167 @@ private void OnMerge(object? sender, RoutedEventArgs e) } private void OnCancel(object? sender, RoutedEventArgs e) => Close(); + + /// + /// The keys of the list. The arrows and Delete act only while the list itself has the focus, so they never move a + /// selection while a button has it; Alt+Up / Alt+Down reorder from anywhere in the window. Text and choices keep their keys. + /// + private void OnKeyDownInList(object? sender, KeyEventArgs e) + { + if (DataContext is not MergeOrderViewModel vm || IsEditing(e.Source)) + return; + var alt = e.KeyModifiers.HasFlag(KeyModifiers.Alt); + var inList = IsWithin(e.Source, FileOrder); + var plain = e.KeyModifiers == KeyModifiers.None && inList; + switch (e.Key) + { + case Key.Left when plain: + vm.SelectLeftCommand.Execute(null); + break; + case Key.Right when plain: + vm.SelectRightCommand.Execute(null); + break; + case Key.Up when plain: + vm.SelectNeighbour(-ColumnCount()); + break; + case Key.Down when plain: + vm.SelectNeighbour(ColumnCount()); + break; + case Key.Delete when plain: + vm.DeleteSelectedCommand.Execute(null); + break; + case Key.Up when alt && vm.MoveUpCommand.CanExecute(null): + vm.MoveUpCommand.Execute(null); + break; + case Key.Down when alt && vm.MoveDownCommand.CanExecute(null): + vm.MoveDownCommand.Execute(null); + break; + default: + return; + } + e.Handled = true; + // The focus stays on the list's selected card; a key pressed on a button keeps the focus where it is. + if (inList) + FocusSelected(); + } + + private static bool IsWithin(object? source, Visual container) => + source is Visual visual && (ReferenceEquals(visual, container) || visual.GetVisualAncestors().Contains(container)); + + /// Focus goes to the selected card, once the list has laid it out (a moved card is new to the list). + private void FocusSelected() => Dispatcher.UIThread.Post(() => + { + if (DataContext is not MergeOrderViewModel vm || vm.SelectedFile is not { } selected) + { + FileOrder.Focus(); + return; + } + if (FileOrder.ContainerFromIndex(vm.Files.IndexOf(selected)) is Control card) + card.Focus(); + }, DispatcherPriority.Background); + + /// Whether the focus is in a box or a choice that keeps its own arrows (a text box, a number, a colour, a radio button, a sort list). + private static bool IsEditing(object? source) => + source is Visual visual && (visual is TextBox or NumericUpDown or ColorPicker or RadioButton or ComboBox + || visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker or RadioButton or ComboBox)); + + /// How many cards are in a row: the cards from the first one that sit on its line. + private int ColumnCount() + { + if (CardBounds(0) is not { } first) + return 1; + var columns = 0; + for (var i = 0; i < CardCount() && CardBounds(i) is { } bounds && Math.Abs(bounds.Top - first.Top) < 1; i++) + columns++; + return Math.Max(1, columns); + } + + private void OnCardPressed(object? sender, PointerPressedEventArgs e) + { + // A card's X takes its file out with its own click: no drag starts there. + if (IsInButton(e.Source)) + return; + var at = e.GetPosition(FileOrder); + if (!e.GetCurrentPoint(FileOrder).Properties.IsLeftButtonPressed || IndexOfCardAt(at) is not { } index) + return; + _pressed = (index, at); + _dragging = false; + e.Pointer.Capture(FileOrder); + } + + private void OnCardMoved(object? sender, PointerEventArgs e) + { + if (_pressed is not { } press) + return; + var at = e.GetPosition(FileOrder); + if (!_dragging && Math.Abs(at.X - press.Start.X) + Math.Abs(at.Y - press.Start.Y) < DragThreshold) + return; + _dragging = true; + // Drawn above the other cards while it is dragged. + if (FileOrder.ContainerFromIndex(press.Index) is { } card) + { + card.ZIndex = 1; + card.RenderTransform = new TranslateTransform(at.X - press.Start.X, at.Y - press.Start.Y); + } + } + + private void OnCardReleased(object? sender, PointerReleasedEventArgs e) + { + if (_pressed is not { } press) + return; + var dragged = _dragging; + var at = e.GetPosition(FileOrder); + _pressed = null; + _dragging = false; + e.Pointer.Capture(null); + + // The card goes back to its place first: the layout is what the drop position is measured on. + if (FileOrder.ContainerFromIndex(press.Index) is { } card) + { + card.RenderTransform = null; + card.ZIndex = 0; + } + if (dragged && DataContext is MergeOrderViewModel vm) + vm.MoveFile(press.Index, IndexNearest(at)); + } + + /// Whether the language is written right to left: the cards are laid out, and read, from the right. + private static bool IsRightToLeft => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft; + + private static bool IsInButton(object? source) => + source is Visual visual && (visual is Button || visual.GetVisualAncestors().Any(ancestor => ancestor is Button)); + + /// The card the point is over, or null. + private int? IndexOfCardAt(Point point) + { + for (var i = 0; i < CardCount(); i++) + if (CardBounds(i) is { } bounds && bounds.Contains(point)) + return i; + return null; + } + + /// The card whose centre is nearest the point: where a dragged card is dropped. + private int IndexNearest(Point point) + { + var nearest = 0; + var distance = double.MaxValue; + for (var i = 0; i < CardCount(); i++) + { + if (CardBounds(i) is not { } bounds) + continue; + var centre = bounds.Center; + var d = (centre.X - point.X) * (centre.X - point.X) + (centre.Y - point.Y) * (centre.Y - point.Y); + if (d < distance) + (nearest, distance) = (i, d); + } + return nearest; + } + + private int CardCount() => (DataContext as MergeOrderViewModel)?.Files.Count ?? 0; + + /// The bounds of a card, in 's coordinates; null before it is laid out. + private Rect? CardBounds(int index) => + FileOrder.ContainerFromIndex(index) is { } card && card.TranslatePoint(new Point(), FileOrder) is { } origin + ? new Rect(origin, card.Bounds.Size) + : null; } diff --git a/src/Filee.App/Views/Pages/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml index 0d30079c2..6f1d2bb9b 100644 --- a/src/Filee.App/Views/Pages/GeneralPage.axaml +++ b/src/Filee.App/Views/Pages/GeneralPage.axaml @@ -57,6 +57,8 @@ + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml index aae555da6..998332931 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml +++ b/src/Filee.App/Views/Pages/HomePage.axaml @@ -18,6 +18,15 @@ + + + @@ -69,6 +78,64 @@ + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml.cs b/src/Filee.App/Views/Pages/HomePage.axaml.cs index 901ebdd3b..f91b96e78 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml.cs +++ b/src/Filee.App/Views/Pages/HomePage.axaml.cs @@ -1,23 +1,43 @@ -// Drop zone: files dropped on the Home page open the donut (click mode) at the drop position, also pictures, links and -// downloads dragged from a browser. A double-click opens the file picker, and the donut opens for the picked files. +// Drop zone: files dropped on the Home page join the queue below it, and so do the files a double-click picks. The +// queue's button opens the donut (click mode) at itself for the queued files, in the order shown. Pictures, links and +// downloads dragged from a browser open the donut at the drop position, as before, or join the queue when Settings asks +// for it (saved to the download folder first). A card dragged to another +// place in the queue changes the order the files are converted or merged in. A click selects a card, and the Delete key +// takes the selected file out of the queue. +using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.VisualTree; using Filee.App.Services; +using Filee.App.ViewModels.Pages; +using Filee.Core.Settings; +using Filee.Core.Web; using Microsoft.Extensions.Logging; namespace Filee.App.Views.Pages; public partial class HomePage : UserControl { + /// Pixels a press may move before it counts as a drag of a card rather than a click. + private const double DragThreshold = 6; + /// /// The first drag over the drop zone of this run is logged: a log without it after "drops don't work" means the /// drag never reached Filee (Windows refuses drops from Explorer into a Filee running as administrator). /// private static int _dragEnterLogged; + /// The queue card under a press: its index, and where the press began (in ). + private (int Index, Point Start)? _pressed; + + /// The press has moved far enough to drag its card. + private bool _dragging; + public HomePage() { InitializeComponent(); @@ -30,12 +50,27 @@ public HomePage() if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0) AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files); }); - DragDrop.AddDragOverHandler(DropZone, (_, e) => - e.DragEffects = DraggedFiles.Offered(e.DataTransfer) ? DragDropEffects.Copy : DragDropEffects.None); + DragDrop.AddDragOverHandler(DropZone, OfferDrop); DragDrop.AddDragLeaveHandler(DropZone, (_, _) => DropZone.Classes.Remove("over")); DragDrop.AddDropHandler(DropZone, OnDrop); + // The queue takes drops too, so files can be added next to the ones already waiting. + DragDrop.AddDragEnterHandler(QueueArea, OfferDrop); + DragDrop.AddDragOverHandler(QueueArea, OfferDrop); + DragDrop.AddDropHandler(QueueArea, OnDrop); + QueueList.PointerPressed += OnQueuePressed; + QueueList.PointerMoved += OnQueueMoved; + QueueList.PointerReleased += OnQueueReleased; + QueueList.Focusable = true; + QueueList.FlowDirection = IsRightToLeft ? Avalonia.Media.FlowDirection.RightToLeft : Avalonia.Media.FlowDirection.LeftToRight; + // Tunnelled: the arrows and Delete reach the page before the list's own navigation, which moves the focus. + AddHandler(KeyDownEvent, OnPageKeyDown, RoutingStrategies.Tunnel); } + private HomePageViewModel? ViewModel => DataContext as HomePageViewModel; + + private static void OfferDrop(object? sender, DragEventArgs e) => + e.DragEffects = DraggedFiles.Offered(e.DataTransfer) ? DragDropEffects.Copy : DragDropEffects.None; + private async void OnDropZoneDoubleTapped(object? sender, TappedEventArgs e) { e.Handled = true; @@ -53,12 +88,7 @@ private async void OnDropZoneDoubleTapped(object? sender, TappedEventArgs e) if (paths.Count == 0) return; - var dropZonePos = e.GetPosition(DropZone); - var targetPos = new Rect(0, 0, DropZone.Bounds.Width, DropZone.Bounds.Height).Contains(dropZonePos) - ? dropZonePos - : new Point(DropZone.Bounds.Width / 2, DropZone.Bounds.Height / 2); - var screenPoint = DropZone.PointToScreen(targetPos); - AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, paths); + TakeFiles(paths, DropZone.TranslatePoint(new Point(DropZone.Bounds.Width / 2, DropZone.Bounds.Height / 2), this) ?? new Point()); } private void OnDrop(object? sender, DragEventArgs e) @@ -72,7 +102,208 @@ private void OnDrop(object? sender, DragEventArgs e) } e.DragEffects = DragDropEffects.Copy; + if (dragged.Web is null) + { + TakeFiles(dragged.Files, e.GetPosition(this)); + return; + } + if (AppHost.Get().Settings.CollectDroppedFiles) + { + _ = AddBrowserFilesAsync(dragged.Web); + return; + } var screenPoint = this.PointToScreen(e.GetPosition(this)); AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, dragged.Files, web: dragged.Web); } + + /// + /// Files dropped or picked: they join the list when the setting asks for it; otherwise the donut opens for them at the + /// point, as before the list existed. + /// + private void TakeFiles(IReadOnlyList paths, Point at) + { + if (AppHost.Get().Settings.CollectDroppedFiles) + { + ViewModel?.AddToQueue(paths); + return; + } + var screenPoint = this.PointToScreen(at); + AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, paths); + } + + /// + /// A browser drag joins the list: a list holds paths, so the files are saved first, into the download folder from + /// Settings (as the crop overlay does), and their results go next to them there. Those that can't be saved are left + /// out and logged. The list outlives the page, so the files join it even when the page changed meanwhile. + /// + private static async Task AddBrowserFilesAsync(WebDrop web) + { + var saved = await AppHost.Get().SaveBrowserFilesAsync(web, + (placeholder, ex) => AppHost.Get>().LogWarning(ex, "A browser file could not be saved for the queue: {Placeholder}", placeholder)); + AppHost.Get().Add(saved); + } + + /// The queue's button: the donut opens at the button for the queued files, in the order they are in. + private void OnConvertQueue(object? sender, RoutedEventArgs e) + { + if (ViewModel is not { Queue.Count: > 0 } vm) + return; + var screenPoint = ConvertQueueButton.PointToScreen(new Point(ConvertQueueButton.Bounds.Width / 2, ConvertQueueButton.Bounds.Height / 2)); + AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, vm.QueuedPaths, keepOrder: true, onJob: vm.ConversionStarted); + // The cards leave the list when that conversion completes (StagedFiles). + vm.MarkSentToDonut(); + } + + private void OnQueuePressed(object? sender, PointerPressedEventArgs e) + { + var at = e.GetPosition(QueueList); + if (!e.GetCurrentPoint(QueueList).Properties.IsLeftButtonPressed || IndexOfCardAt(at) is not { } index) + return; + _pressed = (index, at); + _dragging = false; + e.Pointer.Capture(QueueList); + } + + private void OnQueueMoved(object? sender, PointerEventArgs e) + { + if (_pressed is not { } press) + return; + var at = e.GetPosition(QueueList); + if (!_dragging && Math.Abs(at.X - press.Start.X) + Math.Abs(at.Y - press.Start.Y) < DragThreshold) + return; + _dragging = true; + // Drawn above the other cards while it is dragged. + if (QueueList.ContainerFromIndex(press.Index) is { } card) + { + card.ZIndex = 1; + card.RenderTransform = new TranslateTransform(at.X - press.Start.X, at.Y - press.Start.Y); + } + } + + private void OnQueueReleased(object? sender, PointerReleasedEventArgs e) + { + if (_pressed is not { } press) + return; + var dragged = _dragging; + var at = e.GetPosition(QueueList); + _pressed = null; + _dragging = false; + e.Pointer.Capture(null); + + // The card goes back to its place first: the layout is what the drop position is measured on. + if (QueueList.ContainerFromIndex(press.Index) is { } card) + { + card.RenderTransform = null; + card.ZIndex = 0; + } + if (!dragged) + { + ViewModel?.SelectQueued(press.Index); + QueueList.Focus(); + return; + } + var target = IndexNearest(at); + if (target != press.Index) + ViewModel?.MoveQueued(press.Index, target); + } + + /// + /// The keys of the queue. Alt+Up / Alt+Down move the selected card from anywhere on the page. The arrows and Delete act + /// only while the queue itself has the focus, so they never move the selection while a button has it. A text box keeps + /// its own keys. + /// + private void OnPageKeyDown(object? sender, KeyEventArgs e) + { + if (ViewModel is not { } vm || IsTextEntry(e.Source)) + return; + var alt = e.KeyModifiers.HasFlag(KeyModifiers.Alt); + var inQueue = IsWithin(e.Source, QueueList); + if (alt && e.Key is Key.Up or Key.Down) + { + // Only with a card selected: with none, the keys are left to the page. + if (!vm.Queue.Any(card => card.IsSelected)) + return; + vm.MoveSelectedQueued(e.Key == Key.Down ? 1 : -1); + } + else if (!inQueue || e.KeyModifiers != KeyModifiers.None) + return; + else + switch (e.Key) + { + case Key.Left: + vm.SelectLeftCommand.Execute(null); + break; + case Key.Right: + vm.SelectRightCommand.Execute(null); + break; + case Key.Up: + vm.SelectNeighbour(-ColumnCount()); + break; + case Key.Down: + vm.SelectNeighbour(ColumnCount()); + break; + case Key.Delete: + vm.DeleteSelectedCommand.Execute(null); + break; + default: + return; + } + e.Handled = true; + if (!alt) + QueueList.Focus(); + } + + private static bool IsWithin(object? source, Visual container) => + source is Visual visual && (ReferenceEquals(visual, container) || visual.GetVisualAncestors().Contains(container)); + + /// How many cards are in a row: the cards from the first one that sit on its line. + private int ColumnCount() + { + if (CardBounds(0) is not { } first) + return 1; + var columns = 0; + for (var i = 0; i < QueueCount() && CardBounds(i) is { } bounds && Math.Abs(bounds.Top - first.Top) < 1; i++) + columns++; + return Math.Max(1, columns); + } + + internal static bool IsRightToLeft => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft; + + private static bool IsTextEntry(object? source) => + source is TextBox or NumericUpDown or ColorPicker + || source is Visual visual && visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker); + + /// The queue card the point is over, or null. + private int? IndexOfCardAt(Point point) + { + for (var i = 0; i < QueueCount(); i++) + if (CardBounds(i) is { } bounds && bounds.Contains(point)) + return i; + return null; + } + + /// The card whose centre is nearest the point: where a dragged card is dropped. + private int IndexNearest(Point point) + { + var nearest = 0; + var distance = double.MaxValue; + for (var i = 0; i < QueueCount(); i++) + { + if (CardBounds(i) is not { } bounds) + continue; + var centre = bounds.Center; + var d = (centre.X - point.X) * (centre.X - point.X) + (centre.Y - point.Y) * (centre.Y - point.Y); + if (d < distance) + (nearest, distance) = (i, d); + } + return nearest; + } + + private int QueueCount() => ViewModel?.Queue.Count ?? 0; + + /// The bounds of a queue card, in 's coordinates; null before it is laid out. + private Rect? CardBounds(int index) => + QueueList.ContainerFromIndex(index) is { } card && card.TranslatePoint(new Point(), QueueList) is { } origin + ? new Rect(origin, card.Bounds.Size) + : null; } diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index b19cd74ee..58a3ad89a 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -255,6 +255,11 @@ public sealed class AppSettings /// Every conversion keeps the created and modified dates of the original, whatever the preset says. public bool KeepFileDates { get; set; } + /// + /// Files dropped or picked on the Home page wait in its list (true), instead of opening the donut for each drop. + /// + public bool CollectDroppedFiles { get; set; } = false; + /// Every geospatial conversion is saved as a ZIP, whatever the preset says (see ). public bool ZipGeoResults { get; set; } diff --git a/tests/Filee.App.Tests/BrowserDropTests.cs b/tests/Filee.App.Tests/BrowserDropTests.cs index 0cdbdf810..71b387368 100644 --- a/tests/Filee.App.Tests/BrowserDropTests.cs +++ b/tests/Filee.App.Tests/BrowserDropTests.cs @@ -424,6 +424,61 @@ public void A_picture_dropped_on_the_drop_zone_opens_the_donut_for_it_and_closin } } + [AvaloniaFact] + public void A_picture_dropped_on_the_drop_zone_joins_the_queue_when_the_setting_asks_for_it() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var collect = store.Settings.CollectDroppedFiles; + store.Settings.CollectDroppedFiles = true; + var downloads = Directory.CreateTempSubdirectory("filee-queue-downloads-").FullName; + store.Settings.BrowserDrops.Folder = downloads; + var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), store); + vm.Navigate("home"); + var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 }; + window.Show(); + Pump(); + var radial = AppHost.Get(); + try + { + var page = window.GetVisualDescendants().OfType().Single(); + var zone = page.FindControl("DropZone")!; + var file = VirtualFile.Create("cat.png", Png); + using var data = ChromePicture(file); + // The donut's files as they were: a donut an earlier test left behind must not count as this drop's. + var donutFiles = AppHost.Get().Files.ToList(); + + zone.RaiseEvent(new DragEventArgs(DragDrop.DropEvent, data, zone, new Point(20, 20), KeyModifiers.None)); + var model = Assert.IsType(vm.CurrentPage); + // The file is saved first, on a background thread; the queue takes it once it is on disk. + for (var i = 0; i < 500 && model.Queue.Count == 0; i++) + { + Pump(); + Thread.Sleep(10); + } + + var queued = Assert.Single(model.QueuedPaths); + Assert.Equal("cat.png", Path.GetFileName(queued)); + Assert.True(File.Exists(queued)); + // Saved into the download folder, as the crop overlay does: the result goes next to it there, not into a + // work folder that the next start deletes. + Assert.Equal(downloads, Path.GetDirectoryName(queued)); + // The donut was not offered the file. + Assert.Equal(donutFiles, AppHost.Get().Files); + Assert.True(file.Disposed); + } + finally + { + radial.Close(animate: false); + window.Close(); + // The queue is shared by the whole app: the test's file must not stay in it for the next test. + AppHost.Get().Clear(); + store.Settings.CollectDroppedFiles = collect; + store.Settings.BrowserDrops.Folder = ""; + Directory.Delete(downloads, true); + } + } + [AvaloniaTheory] [InlineData("en")] [InlineData("ko")] diff --git a/tests/Filee.App.Tests/DiagnosticsTests.cs b/tests/Filee.App.Tests/DiagnosticsTests.cs index 0e9e157a0..1de6c6a18 100644 --- a/tests/Filee.App.Tests/DiagnosticsTests.cs +++ b/tests/Filee.App.Tests/DiagnosticsTests.cs @@ -300,7 +300,7 @@ public void The_home_page_warns_and_shows_a_failed_restart() TestServices.EnsureInitialized(); var elevation = Create(relaunchError: "Explorer did not answer."); using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), - AppHost.Get(), AppHost.Get(), elevation: elevation); + AppHost.Get(), AppHost.Get(), AppHost.Get(), elevation: elevation); var changed = new List(); page.PropertyChanged += (_, e) => changed.Add(e.PropertyName); Assert.True(page.IsElevated); @@ -319,7 +319,7 @@ public void The_home_page_has_no_warning_by_default() TestServices.EnsureInitialized(); using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), AppHost.Get(), AppHost.Get(), - elevation: AppHost.Get()); // tests never run Program: the state stays unknown + AppHost.Get(), elevation: AppHost.Get()); // tests never run Program: the state stays unknown Assert.False(page.IsElevated); } } diff --git a/tests/Filee.App.Tests/FileQueueTests.cs b/tests/Filee.App.Tests/FileQueueTests.cs new file mode 100644 index 000000000..629e36021 --- /dev/null +++ b/tests/Filee.App.Tests/FileQueueTests.cs @@ -0,0 +1,78 @@ +// The Home page's queue (multi-file conversion): any files, in the order the user puts them in, and a merge that keeps +// that order instead of sorting by name. + +using Filee.App.Services; +using Filee.App.ViewModels; +using Xunit; + +namespace Filee.App.Tests; + +public class FileQueueTests +{ + [Fact] + public void FilesOfAnyTypeJoinTheQueueInTheOrderAdded() + { + var queue = new FileQueue(); + + queue.Add(["/docs/one.pdf", "/docs/letter.docx", "/photos/photo.png", "/docs/notes.unknownext"]); + + Assert.Equal(["/docs/one.pdf", "/docs/letter.docx", "/photos/photo.png", "/docs/notes.unknownext"], queue.Paths); + } + + [Fact] + public void AFileAddedTwiceIsKeptOnce() + { + var queue = new FileQueue(); + + queue.Add(["/docs/one.pdf"]); + queue.Add(["/docs/one.pdf", "/docs/two.pdf"]); + + Assert.Equal(["/docs/one.pdf", "/docs/two.pdf"], queue.Paths); + } + + [Fact] + public void MovingAFileChangesTheOrder() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf", "/docs/b.pdf", "/docs/c.pdf"]); + + queue.Move(2, 0); + + Assert.Equal(["/docs/c.pdf", "/docs/a.pdf", "/docs/b.pdf"], queue.Paths); + } + + [Fact] + public void RemovingAFileKeepsTheOthersInOrder() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf", "/photos/b.png", "/docs/c.pdf"]); + + Assert.True(queue.Remove("/photos/b.png")); + + Assert.Equal(["/docs/a.pdf", "/docs/c.pdf"], queue.Paths); + Assert.False(queue.Remove("/photos/b.png")); + } + + [Fact] + public void ClearEmptiesTheQueue() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf"]); + + queue.Clear(); + + Assert.Empty(queue.Paths); + } + + [Fact] + public void AMergeKeepsTheOrderTheFilesCameIn() + { + var files = new[] { "/docs/page 10.pdf", "/docs/page 2.pdf", "/docs/page 1.pdf" }; + + var kept = new MergeOrderViewModel(files, keepOrder: true); + var sorted = new MergeOrderViewModel(files); + + Assert.Equal(files, kept.OrderedFiles); + Assert.Equal(["/docs/page 1.pdf", "/docs/page 2.pdf", "/docs/page 10.pdf"], sorted.OrderedFiles); + } +} diff --git a/tests/Filee.App.Tests/HomeKeysTests.cs b/tests/Filee.App.Tests/HomeKeysTests.cs new file mode 100644 index 000000000..3ac83a597 --- /dev/null +++ b/tests/Filee.App.Tests/HomeKeysTests.cs @@ -0,0 +1,104 @@ +// The keys of the Home page's queue: the arrows select a card (Up and Down by a row) only while the queue has the focus, +// so a button keeps its own focus and the selection stays where it is. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.ViewModels.Pages; +using Filee.App.Views; +using Filee.App.Views.Pages; +using Filee.Core.Localization; +using Filee.Core.Settings; +using PdfSharp.Pdf; + +namespace Filee.App.Tests; + +[Collection("MainWindow")] +public class HomeKeysTests +{ + private static void Pump() + { + for (var i = 0; i < 20; i++) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + } + + [AvaloniaFact] + public void Arrows_select_the_queued_cards_only_while_the_queue_has_the_focus() + { + TestServices.EnsureInitialized("en"); + var folder = Directory.CreateTempSubdirectory("filee-home-keys-").FullName; + var paths = new List(); + for (var i = 1; i <= 6; i++) + { + var path = Path.Combine(folder, $"card {i}.pdf"); + using var document = new PdfDocument(); + document.AddPage(); + document.Save(path); + paths.Add(path); + } + var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), AppHost.Get()); + vm.Navigate("home"); + var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 }; + window.Show(); + Pump(); + try + { + var page = window.GetVisualDescendants().OfType().Single(); + var model = Assert.IsType(vm.CurrentPage); + model.AddToQueue(paths); + Pump(); + var queue = page.FindControl("QueueList")!; + int Selected() => model.Queue.ToList().FindIndex(card => card.IsSelected); + + queue.Focus(); + Pump(); + model.SelectQueued(0); + window.KeyPress(Key.Down, RawInputModifiers.None, PhysicalKey.ArrowDown, null); + Pump(); + var top = queue.ContainerFromIndex(0)!.TranslatePoint(new Point(), queue)!.Value.Y; + var columns = Enumerable.Range(0, paths.Count) + .Count(i => Math.Abs(queue.ContainerFromIndex(i)!.TranslatePoint(new Point(), queue)!.Value.Y - top) < 1); + Assert.Equal(columns, Selected()); + + window.KeyPress(Key.Up, RawInputModifiers.None, PhysicalKey.ArrowUp, null); + Pump(); + Assert.Equal(0, Selected()); + + // A button has the focus: the arrows leave the queue's selection alone. + var button = page.GetVisualDescendants().OfType