From bea922d6466428138c025ba635c5ab71893b9a1b Mon Sep 17 00:00:00 2001 From: arielsrossi Date: Sat, 31 Oct 2026 00:27:34 -0400 Subject: [PATCH] feat: queue several files on the Home page before converting Files dropped on the Home page or picked with the double-click dialog join a queue below the drop zone instead of opening the donut at once. The first file sets the type: files of another format are refused with a notice over the drop zone, with the window dimmed behind it. Each queued file shows its first page (PDF) or its picture, numbered in the theme's accent circle, can be dragged to another place, and can be removed with its X. "Choose action" opens the donut for the queue in that order, and the queue empties once that conversion finishes. A merge started from the queue keeps the order and does not open the merge-order chooser (merged pictures still do, for their layout). Co-Authored-By: Claude Haiku 5.5 --- src/Filee.App/Assets/i18n/ar.json | 7 + src/Filee.App/Assets/i18n/de.json | 7 + src/Filee.App/Assets/i18n/en.json | 7 + src/Filee.App/Assets/i18n/es.json | 7 + src/Filee.App/Assets/i18n/fr.json | 7 + src/Filee.App/Assets/i18n/hi.json | 7 + src/Filee.App/Assets/i18n/id.json | 7 + src/Filee.App/Assets/i18n/it.json | 7 + src/Filee.App/Assets/i18n/ja.json | 7 + src/Filee.App/Assets/i18n/ko.json | 7 + src/Filee.App/Assets/i18n/ne.json | 7 + src/Filee.App/Assets/i18n/pl.json | 7 + src/Filee.App/Assets/i18n/pt-BR.json | 7 + src/Filee.App/Assets/i18n/pt-PT.json | 7 + src/Filee.App/Assets/i18n/ru.json | 7 + src/Filee.App/Assets/i18n/th.json | 7 + src/Filee.App/Assets/i18n/tr.json | 7 + src/Filee.App/Assets/i18n/zh-CN.json | 7 + src/Filee.App/Assets/i18n/zh-TW.json | 7 + src/Filee.App/Services/FileQueue.cs | 85 +++++++ src/Filee.App/Services/RadialController.cs | 18 +- src/Filee.App/Services/WindowService.cs | 5 +- .../ViewModels/MergeOrderViewModel.cs | 13 +- .../ViewModels/Pages/HomePageViewModel.cs | 142 ++++++++++- .../ViewModels/Pages/StagedFileViewModel.cs | 72 ++++++ src/Filee.App/Views/MainWindow.axaml | 6 + src/Filee.App/Views/Pages/HomePage.axaml | 51 ++++ src/Filee.App/Views/Pages/HomePage.axaml.cs | 229 +++++++++++++++++- tests/Filee.App.Tests/FileQueueTests.cs | 97 ++++++++ 29 files changed, 829 insertions(+), 22 deletions(-) create mode 100644 src/Filee.App/Services/FileQueue.cs create mode 100644 src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs create mode 100644 tests/Filee.App.Tests/FileQueueTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 028a9c70..b08ada36 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -25,6 +25,13 @@ "close.dont_ask": "عدم السؤال مجددًا", "home.title": "الرئيسية", "home.drop_title": "أفلت الملفات هنا", + "home.queue_title": "الملفات المراد تحويلها", + "home.queue_hint": "اسحب بطاقة لتغيير ترتيبها. يمكن إضافة الملفات من النوع نفسه للملف الأول فقط.", + "home.queue_convert": "اختر إجراءً", + "home.queue_clear": "مسح القائمة", + "home.queue_remove": "إزالة", + "home.queue_only_type": "لا يمكن إضافة إلا ملفات {0} إلى هذه القائمة.", + "home.queue_unsupported": "لا يمكن تحويل نوع الملف هذا.", "home.drop_select": "انقر نقرًا مزدوجًا لتحديد الملفات", "home.drop_hint": "أو اضغط باستمرار على {0} أثناء سحب الملفات في مستكشف الملفات. يفتح شريط الأدوات الدائري عند المؤشر.", "home.drop_hint_macos": "أو اضغط باستمرار على {0} أثناء سحب الملفات في Finder. يفتح شريط الأدوات الدائري عند المؤشر.", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index d19add20..33550668 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -25,6 +25,13 @@ "close.dont_ask": "Nicht erneut fragen", "home.title": "Start", "home.drop_title": "Dateien hier ablegen", + "home.queue_title": "Zu konvertierende Dateien", + "home.queue_hint": "Ziehe eine Karte, um die Reihenfolge zu ändern. Es können nur Dateien desselben Typs wie die erste hinzugefügt werden.", + "home.queue_convert": "Aktion wählen", + "home.queue_clear": "Liste leeren", + "home.queue_remove": "Entfernen", + "home.queue_only_type": "Zu dieser Liste können nur {0}-Dateien hinzugefügt werden.", + "home.queue_unsupported": "Dieser Dateityp kann nicht konvertiert werden.", "home.drop_select": "Dateien per Doppelklick auswählen", "home.drop_hint": "Oder beim Ziehen von Dateien im Explorer {0} gedrückt halten. Das Ringmenü öffnet sich am Mauszeiger.", "home.drop_hint_macos": "Oder beim Ziehen von Dateien im Finder {0} gedrückt halten. Das Ringmenü öffnet sich am Mauszeiger.", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index eec07b44..b381cc9d 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -28,6 +28,13 @@ "home.title": "Home", "home.drop_title": "Drop files here", + "home.queue_title": "Files to convert", + "home.queue_hint": "Drag a card to change the order. Only files of the same type as the first one can be added.", + "home.queue_convert": "Choose action", + "home.queue_clear": "Clear list", + "home.queue_remove": "Remove", + "home.queue_only_type": "Only {0} files can be added to this list.", + "home.queue_unsupported": "This file type can't be converted.", "home.drop_select": "Double-click to select files", "home.drop_hint": "Or hold {0} while dragging files in Explorer. The donut toolbar opens at your cursor.", "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8c7c0829..ea54c067 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -25,6 +25,13 @@ "close.dont_ask": "No volver a preguntar", "home.title": "Inicio", "home.drop_title": "Suelta archivos aquí", + "home.queue_title": "Archivos para convertir", + "home.queue_hint": "Arrastra una tarjeta para cambiar el orden. Solo se pueden añadir archivos del mismo tipo que el primero.", + "home.queue_convert": "Elegir acción", + "home.queue_clear": "Vaciar lista", + "home.queue_remove": "Quitar", + "home.queue_only_type": "Solo se pueden añadir archivos {0} a esta lista.", + "home.queue_unsupported": "Este tipo de archivo no se puede convertir.", "home.drop_select": "Doble clic para seleccionar archivos", "home.drop_hint": "O mantén {0} mientras arrastras archivos en el Explorador. El menú circular se abre donde esté el cursor.", "home.drop_hint_macos": "O mantén {0} mientras arrastras archivos en Finder. El menú circular se abre donde esté el cursor.", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 1aa508fa..a4a4a109 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -28,6 +28,13 @@ "home.title": "Accueil", "home.drop_title": "Déposez des fichiers ici", + "home.queue_title": "Fichiers à convertir", + "home.queue_hint": "Faites glisser une carte pour changer l'ordre. Seuls les fichiers du même type que le premier peuvent être ajoutés.", + "home.queue_convert": "Choisir une action", + "home.queue_clear": "Vider la liste", + "home.queue_remove": "Retirer", + "home.queue_only_type": "Seuls les fichiers {0} peuvent être ajoutés à cette liste.", + "home.queue_unsupported": "Ce type de fichier ne peut pas être converti.", "home.drop_select": "Double-cliquez pour sélectionner des fichiers", "home.drop_hint": "Ou maintenez {0} en glissant des fichiers dans l'Explorateur. Le menu circulaire s'ouvre sous le curseur.", "home.drop_hint_macos": "Ou maintenez {0} en glissant des fichiers dans le Finder. Le menu circulaire s'ouvre sous le curseur.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index afa6648c..0df3ccbf 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -25,6 +25,13 @@ "close.dont_ask": "दोबारा न पूछें", "home.title": "होम", "home.drop_title": "फ़ाइलें यहाँ छोड़ें", + "home.queue_title": "रूपांतरित करने वाली फ़ाइलें", + "home.queue_hint": "क्रम बदलने के लिए कार्ड खींचें। केवल पहली फ़ाइल जैसे प्रकार की फ़ाइलें जोड़ी जा सकती हैं।", + "home.queue_convert": "क्रिया चुनें", + "home.queue_clear": "सूची साफ़ करें", + "home.queue_remove": "हटाएं", + "home.queue_only_type": "इस सूची में केवल {0} फ़ाइलें जोड़ी जा सकती हैं।", + "home.queue_unsupported": "यह फ़ाइल प्रकार रूपांतरित नहीं किया जा सकता।", "home.drop_select": "फ़ाइलें चुनने के लिए डबल-क्लिक करें", "home.drop_hint": "या Explorer में फ़ाइलें खींचते समय {0} दबाए रखें। डोनट टूलबार आपके कर्सर पर खुलेगा।", "home.drop_hint_macos": "या Finder में फ़ाइलें खींचते समय {0} दबाए रखें। डोनट टूलबार आपके कर्सर पर खुलेगा।", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 8b224b6a..19d24b15 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -25,6 +25,13 @@ "close.dont_ask": "Jangan tanyakan lagi", "home.title": "Beranda", "home.drop_title": "Lepaskan file di sini", + "home.queue_title": "File untuk dikonversi", + "home.queue_hint": "Seret kartu untuk mengubah urutan. Hanya file dengan jenis yang sama dengan file pertama yang bisa ditambahkan.", + "home.queue_convert": "Pilih tindakan", + "home.queue_clear": "Kosongkan daftar", + "home.queue_remove": "Hapus", + "home.queue_only_type": "Hanya file {0} yang bisa ditambahkan ke daftar ini.", + "home.queue_unsupported": "Jenis file ini tidak dapat dikonversi.", "home.drop_select": "Klik dua kali untuk memilih file", "home.drop_hint": "Atau tahan {0} saat menyeret file di Explorer. Toolbar donut akan terbuka di kursor Anda.", "home.drop_hint_macos": "Atau tahan {0} saat menyeret file di Finder. Toolbar donut akan terbuka di kursor Anda.", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index ab0af57e..ee2ecda0 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -25,6 +25,13 @@ "close.dont_ask": "Non chiedere più", "home.title": "Home", "home.drop_title": "Rilascia qui i file", + "home.queue_title": "File da convertire", + "home.queue_hint": "Trascina una scheda per cambiare l'ordine. Si possono aggiungere solo file dello stesso tipo del primo.", + "home.queue_convert": "Scegli azione", + "home.queue_clear": "Svuota elenco", + "home.queue_remove": "Rimuovi", + "home.queue_only_type": "A questo elenco si possono aggiungere solo file {0}.", + "home.queue_unsupported": "Questo tipo di file non può essere convertito.", "home.drop_select": "Doppio clic per selezionare i file", "home.drop_hint": "Oppure tieni premuto {0} mentre trascini i file in Esplora file. La barra donut si apre sul cursore.", "home.drop_hint_macos": "Oppure tieni premuto {0} mentre trascini i file nel Finder. La barra donut si apre sul cursore.", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5f18b62d..5a3c23ff 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -25,6 +25,13 @@ "close.dont_ask": "次回から確認しない", "home.title": "ホーム", "home.drop_title": "ここにファイルをドロップ", + "home.queue_title": "変換するファイル", + "home.queue_hint": "カードをドラッグして順番を変えられます。最初のファイルと同じ種類のファイルだけを追加できます。", + "home.queue_convert": "操作を選択", + "home.queue_clear": "リストをクリア", + "home.queue_remove": "削除", + "home.queue_only_type": "このリストには{0}ファイルだけを追加できます。", + "home.queue_unsupported": "この種類のファイルは変換できません。", "home.drop_select": "ダブルクリックしてファイルを選択", "home.drop_hint": "または、{0}を押しながら\nエクスプローラーでファイルをドラッグしてください。\nカーソル位置にドーナツメニューが開きます。", "home.drop_hint_macos": "または、{0}を押しながら\nFinderでファイルをドラッグしてください。\nカーソル位置にドーナツメニューが開きます。", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index f6630867..51494c24 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -28,6 +28,13 @@ "home.title": "홈", "home.drop_title": "여기에 파일을 놓으세요", + "home.queue_title": "변환할 파일", + "home.queue_hint": "카드를 끌어서 순서를 바꿀 수 있습니다. 첫 번째 파일과 같은 종류의 파일만 추가할 수 있습니다.", + "home.queue_convert": "작업 선택", + "home.queue_clear": "목록 지우기", + "home.queue_remove": "제거", + "home.queue_only_type": "이 목록에는 {0} 파일만 추가할 수 있습니다.", + "home.queue_unsupported": "이 파일 형식은 변환할 수 없습니다.", "home.drop_select": "더블클릭하여 파일 선택", "home.drop_hint": "또는 탐색기에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index b39570fe..87ae1fcd 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -25,6 +25,13 @@ "close.dont_ask": "फेरि नसोध्नुहोस्", "home.title": "गृहपृष्ठ", "home.drop_title": "फाइलहरू यहाँ छाड्नुहोस्", + "home.queue_title": "रूपान्तरण गर्नु पर्ने फाइलहरू", + "home.queue_hint": "क्रम बदल्न कार्ड तान्नुहोस्। पहिलो फाइलकै प्रकारका फाइल मात्र थप्न सकिन्छ।", + "home.queue_convert": "कार्य छान्नुहोस्", + "home.queue_clear": "सूची खाली गर्नुहोस्", + "home.queue_remove": "हटाउनुहोस्", + "home.queue_only_type": "यस सूचीमा केवल {0} फाइल थप्न सकिन्छ।", + "home.queue_unsupported": "यो फाइल प्रकार रूपान्तरण गर्न सकिँदैन।", "home.drop_select": "फाइलहरू छान्न डबल-क्लिक गर्नुहोस्", "home.drop_hint": "वा Explorer मा फाइलहरू तान्दा {0} थिचिराख्नुहोस्। डोनट टुलबार कर्सरमा खुल्नेछ।", "home.drop_hint_macos": "वा Finder मा फाइलहरू तान्दा {0} थिचिराख्नुहोस्। डोनट टुलबार कर्सरमा खुल्नेछ।", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index efdc72b8..18ba0616 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -28,6 +28,13 @@ "home.title": "Start", "home.drop_title": "Upuść pliki tutaj", + "home.queue_title": "Pliki do konwersji", + "home.queue_hint": "Przeciągnij kartę, aby zmienić kolejność. Można dodawać tylko pliki tego samego typu co pierwszy.", + "home.queue_convert": "Wybierz działanie", + "home.queue_clear": "Wyczyść listę", + "home.queue_remove": "Usuń", + "home.queue_only_type": "Do tej listy można dodać tylko pliki {0}.", + "home.queue_unsupported": "Tego typu pliku nie można przekonwertować.", "home.drop_select": "Kliknij dwukrotnie, aby wybrać pliki", "home.drop_hint": "Albo przytrzymaj {0}, przeciągając pliki w Eksploratorze. Pasek-pierścień otworzy się przy kursorze.", "home.drop_hint_macos": "Albo przytrzymaj {0}, przeciągając pliki w Finderze. Pasek-pierścień otworzy się przy kursorze.", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 5f7220f2..6dba60c4 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -28,6 +28,13 @@ "home.title": "Início", "home.drop_title": "Solte os arquivos aqui", + "home.queue_title": "Arquivos para converter", + "home.queue_hint": "Arraste um cartão para mudar a ordem. Só entram arquivos do mesmo tipo do primeiro.", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", + "home.queue_only_type": "Só é possível adicionar arquivos {0} nesta lista.", + "home.queue_unsupported": "Este tipo de arquivo não pode ser convertido.", "home.drop_select": "Clique duas vezes para selecionar arquivos", "home.drop_hint": "Ou segure {0} ao arrastar arquivos no Explorador. O menu circular abre no cursor.", "home.drop_hint_macos": "Ou segure {0} ao arrastar arquivos no Finder. O menu circular abre no cursor.", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 241d43b5..7672021d 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -28,6 +28,13 @@ "home.title": "Página inicial", "home.drop_title": "Larga os ficheiros aqui", + "home.queue_title": "Ficheiros a converter", + "home.queue_hint": "Arraste um cartão para mudar a ordem. Só entram ficheiros do mesmo tipo do primeiro.", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", + "home.queue_only_type": "Só é possível adicionar ficheiros {0} a esta lista.", + "home.queue_unsupported": "Este tipo de ficheiro não pode ser convertido.", "home.drop_select": "Faz duplo clique para selecionar ficheiros", "home.drop_hint": "Ou mantém {0} premido enquanto arrastas ficheiros no Explorador. A barra circular abre junto ao cursor.", "home.drop_hint_macos": "Ou mantém {0} premido enquanto arrastas ficheiros no Finder. A barra circular abre junto ao cursor.", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 596877e5..2f8165b3 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -28,6 +28,13 @@ "home.title": "Главная", "home.drop_title": "Перетащите файлы сюда", + "home.queue_title": "Файлы для конвертации", + "home.queue_hint": "Перетащите карточку, чтобы изменить порядок. Можно добавлять только файлы того же типа, что и первый.", + "home.queue_convert": "Выбрать действие", + "home.queue_clear": "Очистить список", + "home.queue_remove": "Удалить", + "home.queue_only_type": "В этот список можно добавлять только файлы {0}.", + "home.queue_unsupported": "Этот тип файла нельзя конвертировать.", "home.drop_select": "Дважды щелкните, чтобы выбрать файлы", "home.drop_hint": "Или зажмите {0}, перетаскивая файлы в Проводнике. Круговое меню откроется у курсора.", "home.drop_hint_macos": "Или зажмите {0}, перетаскивая файлы в Finder. Круговое меню откроется у курсора.", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 856bf23a..c38bc79c 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -25,6 +25,13 @@ "close.dont_ask": "ไม่ต้องถามอีก", "home.title": "หน้าแรก", "home.drop_title": "วางไฟล์ที่นี่", + "home.queue_title": "ไฟล์ที่จะแปลง", + "home.queue_hint": "ลากการ์ดเพื่อเปลี่ยนลำดับ เพิ่มได้เฉพาะไฟล์ชนิดเดียวกับไฟล์แรก", + "home.queue_convert": "เลือกการทำงาน", + "home.queue_clear": "ล้างรายการ", + "home.queue_remove": "นำออก", + "home.queue_only_type": "เพิ่มลงในรายการนี้ได้เฉพาะไฟล์ {0} เท่านั้น", + "home.queue_unsupported": "ไม่สามารถแปลงไฟล์ชนิดนี้ได้", "home.drop_select": "ดับเบิลคลิกเพื่อเลือกไฟล์", "home.drop_hint": "หรือกด {0} ค้างไว้ขณะลากไฟล์ใน Explorer เพื่อเปิดแถบโดนัทตรงตำแหน่งเคอร์เซอร์ของคุณ", "home.drop_hint_macos": "หรือกด {0} ค้างไว้ขณะลากไฟล์ใน Finder เพื่อเปิดแถบโดนัทตรงตำแหน่งเคอร์เซอร์ของคุณ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index f3e22f33..b3776152 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -25,6 +25,13 @@ "close.dont_ask": "Bir daha sorma", "home.title": "Ana sayfa", "home.drop_title": "Dosyaları buraya bırakın", + "home.queue_title": "Dönüştürülecek dosyalar", + "home.queue_hint": "Sırayı değiştirmek için bir kartı sürükleyin. Yalnızca ilkiyle aynı türdeki dosyalar eklenebilir.", + "home.queue_convert": "İşlem seç", + "home.queue_clear": "Listeyi temizle", + "home.queue_remove": "Kaldır", + "home.queue_only_type": "Bu listeye yalnızca {0} dosyaları eklenebilir.", + "home.queue_unsupported": "Bu dosya türü dönüştürülemez.", "home.drop_select": "Dosya seçmek için çift tıklayın", "home.drop_hint": "Ya da Dosya Gezgini'nde dosyaları sürüklerken {0} tuşunu basılı tutun. Halka araç çubuğu imlecin yanında açılır.", "home.drop_hint_macos": "Ya da Finder'da dosyaları sürüklerken {0} tuşunu basılı tutun. Halka araç çubuğu imlecin yanında açılır.", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 95be8e54..2276d673 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -28,6 +28,13 @@ "home.title": "首页", "home.drop_title": "将文件拖放到这里", + "home.queue_title": "待转换的文件", + "home.queue_hint": "拖动卡片可更改顺序。只能添加与第一个文件类型相同的文件。", + "home.queue_convert": "选择操作", + "home.queue_clear": "清空列表", + "home.queue_remove": "移除", + "home.queue_only_type": "此列表只能添加 {0} 文件。", + "home.queue_unsupported": "无法转换此文件类型。", "home.drop_select": "双击选择文件", "home.drop_hint": "或在资源管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 24c4110a..079f7af3 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -28,6 +28,13 @@ "home.title": "首頁", "home.drop_title": "將檔案拖放到這裡", + "home.queue_title": "待轉換的檔案", + "home.queue_hint": "拖曳卡片可變更順序。只能加入與第一個檔案類型相同的檔案。", + "home.queue_convert": "選擇動作", + "home.queue_clear": "清空清單", + "home.queue_remove": "移除", + "home.queue_only_type": "此清單只能加入 {0} 檔案。", + "home.queue_unsupported": "無法轉換此檔案類型。", "home.drop_select": "按兩下選取檔案", "home.drop_hint": "或在檔案總管中按住 {0} 拖曳檔案,環形工具列會在游標處開啟。", "home.drop_hint_macos": "或在 Finder 中按住 {0} 拖曳檔案,環形工具列會在游標處開啟。", diff --git a/src/Filee.App/Services/FileQueue.cs b/src/Filee.App/Services/FileQueue.cs new file mode 100644 index 00000000..a634bcdd --- /dev/null +++ b/src/Filee.App/Services/FileQueue.cs @@ -0,0 +1,85 @@ +// The files waiting on the Home page to be converted together. The first file fixes the type: only files of the same +// format join the queue, and the order shown is the order the files are converted (or merged) in. + +using Filee.Core.Formats; +using Filee.Core.Platform; + +namespace Filee.App.Services; + +/// Why a file didn't join the queue. +internal enum QueueRefusal +{ + /// Filee doesn't know the format, so it can't convert the file. + Unsupported, + + /// The queue holds another format; the first file's format decides. + OtherFormat, +} + +/// An ordered list of files of one format (), kept without duplicates. +internal sealed class FileQueue +{ + private readonly List _paths = []; + + /// The format every file in the queue has; null while the queue is empty. + public FileFormat? Format { get; private set; } + + /// The files in the order they are converted. + public IReadOnlyList Paths => _paths; + + /// + /// Adds in order. Files of another format, or of a format Filee doesn't know, are left out + /// and reported in the result; the queue's format is set by the first file added to an empty queue. + /// + public IReadOnlyList<(string Path, QueueRefusal Reason)> Add(IEnumerable paths) + { + var refused = new List<(string, QueueRefusal)>(); + foreach (var path in paths) + { + if (_paths.Contains(path, FileSystemPaths.Comparer)) + continue; + var format = FormatRegistry.Detect(path); + if (format is null) + { + refused.Add((path, QueueRefusal.Unsupported)); + continue; + } + if (Format is { } queued && format.Id != queued.Id) + { + refused.Add((path, QueueRefusal.OtherFormat)); + continue; + } + Format = format; + _paths.Add(path); + } + return refused; + } + + /// Takes out. An empty queue forgets its format. + public bool Remove(string path) + { + var index = _paths.FindIndex(p => FileSystemPaths.Comparer.Equals(p, path)); + if (index < 0) + return false; + _paths.RemoveAt(index); + if (_paths.Count == 0) + Format = null; + 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(); + Format = null; + } +} diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 2ef6fdb2..e06936e7 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -66,6 +66,7 @@ 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 /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -94,6 +95,7 @@ public void ShowForDrag(int x, int y) _dropped = false; _dragReachedDonut = false; _converted = false; + _keepOrder = false; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -114,8 +116,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) { if (web is null) files = platform.ResolveShortcuts(files); @@ -127,6 +132,7 @@ public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnot ReleaseProgressToToast(); ReplaceWeb(web); _toClipboard = toClipboard; + _keepOrder = keepOrder; if (forAnotherProcess && OperatingSystem.IsMacOS()) _grace.Start(); else @@ -364,7 +370,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) { Close(animate: false); - _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard); + _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard, _keepOrder); return; } @@ -389,11 +395,15 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w ShowProgress(job); } - 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) { 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) diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 955b0851..f05dbc45 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -56,12 +56,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); var dialog = new MergeOrderWindow { DataContext = vm }; if (preset is not null) dialog.StartMerge.Content = loc.DisplayName(preset); diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index 7710534b..dff136e7 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -28,12 +28,15 @@ public sealed partial class MergeOrderViewModel : ObservableObject /// "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) { - Files = new(files - .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) - .ThenBy(file => file, NaturalComparer.Instance) - .Select(file => new MergeSource(file))); + IEnumerable ordered = keepOrder + ? files + : files + .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) + .ThenBy(file => file, NaturalComparer.Instance); + Files = new(ordered.Select(file => new MergeSource(file))); SelectedFile = Files.FirstOrDefault(); if (images is null) return; diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index e6487327..7b53959e 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -206,6 +206,10 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; private readonly ElevationService? _elevation; + private readonly FileQueue _queue = new(); + + /// The queued files sent to the donut, until their conversion is in the history (). + private List? _sentToDonut; public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, ConversionService? conversions = null, ElevationService? elevation = null) @@ -223,15 +227,22 @@ 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; + Queue.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasQueue)); Reload(); } - private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload); + private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => + { + FinishSentQueue(); + Reload(); + }); public void Dispose() { _thumbnails?.Cancel(); _store.HistoryChanged -= OnHistoryChanged; + foreach (var card in Queue) + card.Dispose(); if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; if (_elevation is not null) @@ -267,6 +278,135 @@ private void OpenAccessibility() public string DropHint { get; } + /// + /// The files waiting to be converted together, in the order they are converted or merged in. Files are added by + /// drops and the file picker, one type at a time (). + /// + public ObservableCollection Queue { get; } = []; + + public bool HasQueue => Queue.Count > 0; + + /// Why the last files didn't join the queue, or empty. + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasQueueMessage))] + private string _queueMessage = ""; + + public bool HasQueueMessage => QueueMessage.Length > 0; + + /// The queued files' paths in the order they are converted. + public IReadOnlyList QueuedPaths => _queue.Paths.ToArray(); + + /// + /// Adds dropped or picked files to the queue. The first file's format decides which files may join: the others are + /// left out, and says why. + /// + public void AddToQueue(IEnumerable paths) + { + var refused = _queue.Add(paths); + SyncQueue(); + QueueMessage = refused.Count == 0 ? "" : refused[0].Reason switch + { + QueueRefusal.OtherFormat => _loc.Format("home.queue_only_type", FormatLabels.Name(_loc, _queue.Format!)), + _ => _loc["home.queue_unsupported"], + }; + } + + /// Moves a queued file, from the user dragging its card. + public void MoveQueued(int from, int to) + { + _queue.Move(from, to); + Queue.Move(from, to); + Renumber(); + } + + /// The card's X: takes its file out of the queue. + internal void RemoveFromQueue(StagedFileViewModel card) + { + _queue.Remove(card.Path); + SyncQueue(); + QueueMessage = ""; + } + + /// + /// The queue's files went to the donut. The queue empties once Filee has converted them, not before: a cancelled + /// donut keeps the files. + /// + public void MarkSentToDonut() => _sentToDonut = [.. _queue.Paths]; + + /// + /// Empties the queue when a finished conversion (not a cancelled one) of exactly the files sent to the donut is in + /// the history. Such an entry lists all of them as its sources. + /// + private void FinishSentQueue() + { + if (_sentToDonut is not { Count: > 0 } sent) + return; + var finished = _store.History.Any(entry => entry.State != JobState.Cancelled + && sent.All(path => entry.Sources.Contains(path, FileSystemPaths.Comparer))); + if (!finished) + return; + _sentToDonut = null; + ClearQueue(); + } + + /// Empties the queue: the "Clear list" button, and once its files were converted. + [RelayCommand] + public void ClearQueue() + { + _queue.Clear(); + foreach (var card in Queue) + card.Dispose(); + Queue.Clear(); + QueueMessage = ""; + } + + /// The notice's X: closes it. + [RelayCommand] + private void DismissQueueMessage() => QueueMessage = ""; + + /// Brings the cards in line with the queue: cards of files that left it go, new files get a card at the end. + private void SyncQueue() + { + foreach (var card in Queue.Where(card => !_queue.Paths.Contains(card.Path, FileSystemPaths.Comparer)).ToList()) + { + Queue.Remove(card); + card.Dispose(); + } + var format = _queue.Format; + foreach (var path in _queue.Paths) + { + if (format is null || Queue.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path))) + continue; + var card = new StagedFileViewModel(path, format, RemoveFromQueue); + Queue.Add(card); + _ = LoadThumbnailAsync(card, format); + } + Renumber(); + } + + private void Renumber() + { + for (var i = 0; i < Queue.Count; i++) + Queue[i].Position = i + 1; + } + + /// The picture of a card, made off the UI thread; a card that left the queue meanwhile gets none. + private async Task LoadThumbnailAsync(StagedFileViewModel card, FileFormat format) + { + Bitmap? bitmap = null; + try + { + bitmap = await Task.Run(() => StagedFileViewModel.MakeThumbnail(card.Path, format)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the card shows its extension instead. + } + if (Queue.Contains(card)) + card.Thumbnail = bitmap; + else if (format.Category == FormatCategory.Pdf) + bitmap?.Dispose(); + } + /// 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 00000000..470581c1 --- /dev/null +++ b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs @@ -0,0 +1,72 @@ +// One file of the Home page's queue: 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.Engines.Pdf; + +namespace Filee.App.ViewModels.Pages; + +public sealed partial class StagedFileViewModel : ObservableObject, IDisposable +{ + /// Width and height of the picture of a PDF's first page, in pixels (the card shows it smaller). + private const int PdfThumbnailSize = 260; + + private readonly Action _remove; + private readonly bool _ownsThumbnail; + + public StagedFileViewModel(string path, FileFormat format, Action remove) + { + Path = path; + Name = System.IO.Path.GetFileName(path); + FormatBadge = format.PrimaryExtension.TrimStart('.').ToUpperInvariant(); + _remove = remove; + _ownsThumbnail = format.Category == FormatCategory.Pdf; + } + + 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 queue, from 1: the order the files are converted or merged in. + [ObservableProperty] private int _position; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + public bool HasThumbnail => Thumbnail is not null; + + /// + /// Makes the picture off the UI thread: the first page of a PDF, or the picture of an image file. Null for other + /// formats, which show their extension instead. + /// + internal static Bitmap? MakeThumbnail(string path, FileFormat format) + { + if (format.Category == FormatCategory.Pdf) + { + var preview = PdfPageTools.Preview(path, 0, PdfThumbnailSize); + using var stream = new MemoryStream(preview.Png); + return new Bitmap(stream); + } + return format.Category == FormatCategory.Image && HistoryThumbnails.CanShow(path) + ? HistoryThumbnails.Load(path) + : null; + } + + [RelayCommand] + private void Remove() => _remove(this); + + /// Disposes the picture of a PDF (pictures of images are cached and shared, so they stay). + public void Dispose() + { + if (_ownsThumbnail) + Thumbnail?.Dispose(); + Thumbnail = null; + } +} diff --git a/src/Filee.App/Views/MainWindow.axaml b/src/Filee.App/Views/MainWindow.axaml index be53bc76..a5204e05 100644 --- a/src/Filee.App/Views/MainWindow.axaml +++ b/src/Filee.App/Views/MainWindow.axaml @@ -104,5 +104,11 @@ + + + + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml index aae555da..cfa34fe2 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml +++ b/src/Filee.App/Views/Pages/HomePage.axaml @@ -69,6 +69,57 @@ + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml.cs b/src/Filee.App/Views/Pages/HomePage.axaml.cs index 901ebdd3..9d6647ae 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml.cs +++ b/src/Filee.App/Views/Pages/HomePage.axaml.cs @@ -1,23 +1,40 @@ -// 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 still open the donut at the drop position, as before. A card dragged to another +// place in the queue changes the order the files are converted or merged in. +using System.ComponentModel; using Avalonia; +using Avalonia.Automation; 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 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 +47,110 @@ 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; + } + + private HomePageViewModel? ViewModel => DataContext as HomePageViewModel; + + /// The view model whose notice this page shows (its changes are followed while the page is on screen). + private HomePageViewModel? _notified; + + /// The window's notice host and its slot while the queue notice shows (see ). + private Grid? _noticeHost; + private Border? _noticeSlot; + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (_notified is not null) + _notified.PropertyChanged -= OnViewModelChanged; + _notified = DataContext as HomePageViewModel; + if (_notified is not null) + _notified.PropertyChanged += OnViewModelChanged; } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + HideQueueNotice(); + } + + private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(HomePageViewModel.QueueMessage) && _notified is { } vm) + ShowQueueNotice(vm.QueueMessage); + } + + /// + /// The refusal of files of another format: a card on top of the drop zone, and the window dimmed behind it. The + /// window's notice host dims everything (navigation included) and holds the card in its slot, placed over the drop + /// zone. Its X closes the notice. + /// + private void ShowQueueNotice(string message) + { + HideQueueNotice(); + if (message.Length == 0 || _notified is not { } vm || TopLevel.GetTopLevel(this) is not MainWindow window + || window.FindControl("NoticeHost") is not { } host || window.FindControl("NoticeSlot") is not { } slot) + return; + + var close = new Button + { + Width = 26, + Height = 26, + Padding = new Thickness(0), + CornerRadius = new CornerRadius(999), + Margin = new Thickness(12, 0, 0, 0), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + Command = vm.DismissQueueMessageCommand, + Content = new PathIcon { Width = 10, Height = 10, Data = this.FindResource("Icon.Close") as Geometry }, + }; + ToolTip.SetTip(close, this.FindResource("common.close") as string); + var row = new DockPanel(); + DockPanel.SetDock(close, Dock.Right); + row.Children.Add(close); + row.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center }); + var card = new Border + { + Padding = new Thickness(14, 10), + Child = row, + MaxWidth = 420, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + }; + card.Classes.Add("card"); + card.Classes.Add("history-card"); + + // The slot covers the drop zone's box, in the window's own coordinates, and centres the card in it. + var origin = DropZone.TranslatePoint(new Point(0, 0), window) ?? new Point(); + slot.Margin = new Thickness(origin.X, origin.Y, 0, 0); + slot.Width = DropZone.Bounds.Width; + slot.Height = DropZone.Bounds.Height; + slot.Child = card; + host.IsVisible = true; + (_noticeHost, _noticeSlot) = (host, slot); + } + + private void HideQueueNotice() + { + if (_noticeHost is null || _noticeSlot is null) + return; + _noticeHost.IsVisible = false; + _noticeSlot.Child = null; + (_noticeHost, _noticeSlot) = (null, null); + } + + 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 +168,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); + ViewModel?.AddToQueue(paths); } private void OnDrop(object? sender, DragEventArgs e) @@ -72,7 +182,106 @@ private void OnDrop(object? sender, DragEventArgs e) } e.DragEffects = DragDropEffects.Copy; + if (dragged.Web is null) + { + ViewModel?.AddToQueue(dragged.Files); + return; + } var screenPoint = this.PointToScreen(e.GetPosition(this)); AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, dragged.Files, web: dragged.Web); } + + /// 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); + // The queue empties once Filee has converted these files (HomePageViewModel.FinishSentQueue). + 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) + return; + var target = IndexNearest(at); + if (target != press.Index) + ViewModel?.MoveQueued(press.Index, target); + } + + /// 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/tests/Filee.App.Tests/FileQueueTests.cs b/tests/Filee.App.Tests/FileQueueTests.cs new file mode 100644 index 00000000..2ccd9d34 --- /dev/null +++ b/tests/Filee.App.Tests/FileQueueTests.cs @@ -0,0 +1,97 @@ +// The Home page's queue (multi-file conversion): one format at a time, in the order the user puts it in, and a merge that +// keeps that order instead of sorting by name. + +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.Core.Formats; +using Xunit; + +namespace Filee.App.Tests; + +public class FileQueueTests +{ + [Fact] + public void TheFirstFileSetsTheTypeAndOtherFormatsAreRefused() + { + var queue = new FileQueue(); + + var refused = queue.Add(["/docs/one.pdf", "/docs/letter.docx", "/docs/three.pdf", "/docs/photo.png"]); + + Assert.Equal(["/docs/one.pdf", "/docs/three.pdf"], queue.Paths); + Assert.Equal(FormatCategory.Pdf, queue.Format?.Category); + Assert.Equal<(string, QueueRefusal)[]>( + [("/docs/letter.docx", QueueRefusal.OtherFormat), ("/docs/photo.png", QueueRefusal.OtherFormat)], + refused.Select(r => (r.Path, r.Reason)).ToArray()); + } + + [Fact] + public void FilesOfAFormatFileeDoesNotKnowAreRefusedAsUnsupported() + { + var queue = new FileQueue(); + + var refused = queue.Add(["/docs/notes.unknownext"]); + + Assert.Empty(queue.Paths); + Assert.Null(queue.Format); + Assert.Equal(QueueRefusal.Unsupported, Assert.Single(refused).Reason); + } + + [Fact] + public void AFileAddedTwiceIsKeptOnce() + { + var queue = new FileQueue(); + + queue.Add(["/docs/one.pdf"]); + var refused = queue.Add(["/docs/one.pdf", "/docs/two.pdf"]); + + Assert.Equal(["/docs/one.pdf", "/docs/two.pdf"], queue.Paths); + Assert.Empty(refused); + } + + [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 RemovingTheLastFileLetsAnotherTypeStart() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf"]); + + Assert.True(queue.Remove("/docs/a.pdf")); + Assert.Null(queue.Format); + Assert.Empty(queue.Add(["/photos/b.png"])); + Assert.Equal(["/photos/b.png"], queue.Paths); + } + + [Fact] + public void ClearEmptiesTheQueueAndItsType() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf"]); + + queue.Clear(); + + Assert.Empty(queue.Paths); + Assert.Null(queue.Format); + } + + [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); + } +}