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); + } +}