From 89f6ff8581b29579ff15311eb68b85024116f4a2 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:49:10 +0900 Subject: [PATCH] feat(toolbar): show when extraction presets use ordinary conversion Keep extraction presets usable while showing an input-dependent notice in the donut centre and the Available presets tooltip. Cover the three agreed extraction presets, distinguish mixed inputs, and add the notice in every supported language. Test real controls, cached tooltip updates, and per-source extraction flags in a mixed queue. Closes #1091 Co-authored-by: sisyphus-dev-ai --- src/Filee.App/Assets/i18n/ar.json | 6 + src/Filee.App/Assets/i18n/bn-BD.json | 6 + src/Filee.App/Assets/i18n/de.json | 6 + src/Filee.App/Assets/i18n/en.json | 6 + src/Filee.App/Assets/i18n/es.json | 6 + src/Filee.App/Assets/i18n/fa.json | 6 + src/Filee.App/Assets/i18n/fr.json | 6 + src/Filee.App/Assets/i18n/hi.json | 6 + src/Filee.App/Assets/i18n/id.json | 6 + src/Filee.App/Assets/i18n/it.json | 6 + src/Filee.App/Assets/i18n/ja.json | 6 + src/Filee.App/Assets/i18n/ko.json | 6 + src/Filee.App/Assets/i18n/ne.json | 6 + src/Filee.App/Assets/i18n/pl.json | 6 + src/Filee.App/Assets/i18n/pt-BR.json | 6 + src/Filee.App/Assets/i18n/pt-PT.json | 6 + src/Filee.App/Assets/i18n/ru.json | 6 + src/Filee.App/Assets/i18n/th.json | 6 + src/Filee.App/Assets/i18n/tr.json | 6 + src/Filee.App/Assets/i18n/vi.json | 6 + src/Filee.App/Assets/i18n/zh-CN.json | 6 + src/Filee.App/Assets/i18n/zh-TW.json | 6 + src/Filee.App/Controls/DonutMenu.cs | 21 +- src/Filee.App/Services/PresetAvailability.cs | 21 ++ .../ViewModels/Pages/ToolbarPageViewModel.cs | 28 +- src/Filee.App/ViewModels/PresetFormatTip.cs | 1 + src/Filee.App/ViewModels/RadialViewModel.cs | 17 + src/Filee.App/Views/Pages/ToolbarPage.axaml | 2 + src/Filee.App/Views/RadialWindow.axaml | 1 + .../OrdinaryConversionNoticeTests.cs | 332 ++++++++++++++++++ .../PaletteChipTipModelTests.cs | 94 +++++ .../PaletteChipTipViewTests.cs | 102 ++++++ tests/Filee.Core.Tests/JobQueueTests.cs | 42 +++ 33 files changed, 785 insertions(+), 8 deletions(-) create mode 100644 tests/Filee.App.Tests/OrdinaryConversionNoticeTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 972eee166..663dca9e9 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "عدد الملفات: {0}", "donut.drop_to": "أفلت للتحويل إلى {0}", + "donut.ordinary_conversion.single": "يُحوَّل هذا الملف تحويلاً عادياً", + "donut.ordinary_conversion.all": "تُحوَّل جميع الملفات القابلة للتحويل تحويلاً عادياً", + "donut.ordinary_conversion.some": "تُحوَّل بعض الملفات تحويلاً عادياً", "donut.pick_in_group": "حرّك المؤشر إلى الخارج للاختيار", "donut.group_empty": "لم تعد هناك إعدادات مسبقة في هذه المجموعة. عدّلها من الإعدادات ← شريط الأدوات الدائري", "donut.click_to": "انقر للتحويل إلى {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "تفتح التحديدات المختلطة الأخرى «{0}».", "toolbar.mixed_others_self": "لا يوجد ملف تعريف مختلط بلا امتدادات محددة، لذا يُستخدم هذا الملف أيضًا لكل مزيج آخر.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "تُحوَّل صيغة الإدخال هذه تحويلاً عادياً", + "toolbar.chip_tip.ordinary_conversion.all": "تُحوَّل جميع صيغ الإدخال القابلة للتحويل تحويلاً عادياً", + "toolbar.chip_tip.ordinary_conversion.some": "تُحوَّل بعض صيغ الإدخال تحويلاً عادياً", "toolbar.chip_tip.makes_same": "→ التنسيق نفسه", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "الصيغ المقبولة: {0}", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index 6845cfd50..d20d3e54a 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -88,6 +88,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0}টি ফাইল", "donut.drop_to": "ছেড়ে দিন → {0}", + "donut.ordinary_conversion.single": "এই ফাইলটি সাধারণ রূপান্তর হিসেবে চলে", + "donut.ordinary_conversion.all": "রূপান্তরযোগ্য সব ফাইল সাধারণ রূপান্তর হিসেবে চলে", + "donut.ordinary_conversion.some": "কিছু ফাইল সাধারণ রূপান্তর হিসেবে চলে", "donut.pick_in_group": "নির্বাচন করতে বাইরের দিকে সরান", "donut.group_empty": "এই গ্রুপে আর কোনো প্রিসেট অবশিষ্ট নেই। সেটিংস → ডোনাট টুলবারে এটি সম্পাদনা করুন", "donut.click_to": "ক্লিক করুন → {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "অন্যান্য মিশ্র ফাইলের জন্য “{0}” খুলবে।", "toolbar.mixed_others_self": "চিহ্নিত না থাকা কোনো মিশ্র প্রোফাইল অবশিষ্ট নেই, তাই এটি অন্য সব মিশ্রণের জন্যও ব্যবহৃত হবে।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "এই ইনপুট ফরম্যাটটি সাধারণ রূপান্তর হিসেবে চলে", + "toolbar.chip_tip.ordinary_conversion.all": "রূপান্তরযোগ্য সব ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে", + "toolbar.chip_tip.ordinary_conversion.some": "কিছু ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে", "toolbar.chip_tip.makes_same": "→ একই ফরম্যাট", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "গৃহীত ফরম্যাট: {0}", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6190cee2f..1d23d46b4 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} Dateien", "donut.drop_to": "Ablegen → {0}", + "donut.ordinary_conversion.single": "Diese Datei wird normal konvertiert", + "donut.ordinary_conversion.all": "Alle konvertierbaren Dateien werden normal konvertiert", + "donut.ordinary_conversion.some": "Einige Dateien werden normal konvertiert", "donut.pick_in_group": "Zum Auswählen nach außen bewegen", "donut.group_empty": "Diese Gruppe enthält keine Vorlagen mehr. Bearbeite sie unter Einstellungen → Ringmenü", "donut.click_to": "Klicken → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Andere Dateimischungen öffnen „{0}“.", "toolbar.mixed_others_self": "Kein Profil für gemischte Auswahlen ist ohne Auswahl geblieben. Dieses Profil wird daher auch für alle anderen Dateimischungen verwendet.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Dieses Eingabeformat wird normal konvertiert", + "toolbar.chip_tip.ordinary_conversion.all": "Alle konvertierbaren Eingabeformate werden normal konvertiert", + "toolbar.chip_tip.ordinary_conversion.some": "Einige Eingabeformate werden normal konvertiert", "toolbar.chip_tip.makes_same": "→ Gleiches Format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formate: {0}", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 5ad8929cb..a20a6e051 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} files", "donut.drop_to": "Drop → {0}", + "donut.ordinary_conversion.single": "This file runs as an ordinary conversion", + "donut.ordinary_conversion.all": "All convertible files run as ordinary conversions", + "donut.ordinary_conversion.some": "Some files run as ordinary conversions", "donut.pick_in_group": "Move outward to choose", "donut.group_empty": "No presets left in this group. Edit it in Settings → Donut toolbar", "donut.click_to": "Click → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Other mixes of files open “{0}”.", "toolbar.mixed_others_self": "No mixed profile is left with nothing checked, so this one is also used for every other mix.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "This input format runs as an ordinary conversion", + "toolbar.chip_tip.ordinary_conversion.all": "All convertible input formats run as ordinary conversions", + "toolbar.chip_tip.ordinary_conversion.some": "Some input formats run as ordinary conversions", "toolbar.chip_tip.makes_same": "→ Same format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Accepted formats: {0}", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index ba6f22f55..5f7068872 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} archivos", "donut.drop_to": "Suelta → {0}", + "donut.ordinary_conversion.single": "Este archivo usa una conversión normal", + "donut.ordinary_conversion.all": "Todos los archivos convertibles usan una conversión normal", + "donut.ordinary_conversion.some": "Algunos archivos usan una conversión normal", "donut.pick_in_group": "Muévete hacia fuera para elegir", "donut.group_empty": "No quedan ajustes preestablecidos en este grupo. Edítalo en Ajustes → Menú circular", "donut.click_to": "Clic → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Las demás combinaciones de archivos abren «{0}».", "toolbar.mixed_others_self": "Ningún perfil mixto quedó sin nada marcado, así que este también se usa para todas las demás combinaciones.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa una conversión normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos los formatos de entrada convertibles usan una conversión normal", + "toolbar.chip_tip.ordinary_conversion.some": "Algunos formatos de entrada usan una conversión normal", "toolbar.chip_tip.makes_same": "→ Mismo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos admitidos: {0}", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index ff787bfef..291b4dfb9 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -88,6 +88,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} فایل", "donut.drop_to": "رها کردن ← {0}", + "donut.ordinary_conversion.single": "این فایل به‌صورت عادی تبدیل می‌شود", + "donut.ordinary_conversion.all": "همهٔ فایل‌های قابل تبدیل به‌صورت عادی تبدیل می‌شوند", + "donut.ordinary_conversion.some": "بعضی فایل‌ها به‌صورت عادی تبدیل می‌شوند", "donut.pick_in_group": "حرکت به سمت بیرون برای انتخاب", "donut.group_empty": "پیش‌تنظیمی در این گروه باقی نمانده است. آن را در تنظیمات ← نوار ابزار دونات ویرایش کنید", "donut.click_to": "کلیک ← {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "سایر ترکیب‌های فایلی «{0}» را باز می‌کنند.", "toolbar.mixed_others_self": "هیچ نمایه ترکیبی بدون علامت باقی نمانده است، بنابراین این نمایه برای هر ترکیب دیگری نیز استفاده می‌شود.", "toolbar.chip_tip.makes": "← {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "این فرمت ورودی به‌صورت عادی تبدیل می‌شود", + "toolbar.chip_tip.ordinary_conversion.all": "همهٔ فرمت‌های ورودی قابل تبدیل به‌صورت عادی تبدیل می‌شوند", + "toolbar.chip_tip.ordinary_conversion.some": "بعضی فرمت‌های ورودی به‌صورت عادی تبدیل می‌شوند", "toolbar.chip_tip.makes_same": "← همان فرمت", "toolbar.chip_tip.makes_name": "← {0}", "toolbar.chip_tip.works_with": "فرمت‌های پذیرفته‌شده: {0}", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index dfe7b3dc6..7c8a1cbb3 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} fichiers", "donut.drop_to": "Déposer → {0}", + "donut.ordinary_conversion.single": "Ce fichier utilise une conversion normale", + "donut.ordinary_conversion.all": "Tous les fichiers convertibles utilisent une conversion normale", + "donut.ordinary_conversion.some": "Certains fichiers utilisent une conversion normale", "donut.pick_in_group": "Éloignez-vous pour choisir", "donut.group_empty": "Plus aucun préréglage dans ce groupe. Modifiez-le dans Paramètres → Menu circulaire", "donut.click_to": "Clic → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Les autres mélanges de fichiers ouvrent « {0} ».", "toolbar.mixed_others_self": "Il ne reste aucun profil mixte sans case cochée : celui-ci sert donc aussi pour tous les autres mélanges.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Ce format d’entrée utilise une conversion normale", + "toolbar.chip_tip.ordinary_conversion.all": "Tous les formats d’entrée convertibles utilisent une conversion normale", + "toolbar.chip_tip.ordinary_conversion.some": "Certains formats d’entrée utilisent une conversion normale", "toolbar.chip_tip.makes_same": "→ Même format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formats acceptés : {0}", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index e24a35e4b..bf9b82aaf 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} फ़ाइलें", "donut.drop_to": "छोड़ें → {0}", + "donut.ordinary_conversion.single": "यह फ़ाइल सामान्य कन्वर्ज़न के रूप में चलती है", + "donut.ordinary_conversion.all": "कन्वर्ट हो सकने वाली सभी फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं", + "donut.ordinary_conversion.some": "कुछ फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं", "donut.pick_in_group": "चुनने के लिए बाहर की ओर ले जाएँ", "donut.group_empty": "इस समूह में कोई प्रीसेट नहीं बचा है। इसे सेटिंग्स → डोनट टूलबार में बदलें", "donut.click_to": "क्लिक करें → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "फ़ाइलों के अन्य मिश्रण “{0}” खोलते हैं।", "toolbar.mixed_others_self": "कोई मिश्रित प्रोफ़ाइल बिना चयन के नहीं बची है, इसलिए यह प्रोफ़ाइल हर अन्य मिश्रण के लिए भी इस्तेमाल होती है।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "यह इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलता है", + "toolbar.chip_tip.ordinary_conversion.all": "कन्वर्ट हो सकने वाले सभी इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं", + "toolbar.chip_tip.ordinary_conversion.some": "कुछ इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं", "toolbar.chip_tip.makes_same": "→ वही फ़ॉर्मैट", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "स्वीकार्य फ़ॉर्मैट: {0}", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 001a0b25a..9de142916 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} file", "donut.drop_to": "Lepas → {0}", + "donut.ordinary_conversion.single": "File ini menjalankan konversi biasa", + "donut.ordinary_conversion.all": "Semua file yang dapat dikonversi menjalankan konversi biasa", + "donut.ordinary_conversion.some": "Sebagian file menjalankan konversi biasa", "donut.pick_in_group": "Arahkan keluar untuk memilih", "donut.group_empty": "Tidak ada preset tersisa di grup ini. Edit di Pengaturan → Toolbar donut", "donut.click_to": "Klik → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Kombinasi file lainnya membuka “{0}”.", "toolbar.mixed_others_self": "Tidak ada profil campuran yang dibiarkan tanpa centang, sehingga profil ini juga digunakan untuk setiap kombinasi lainnya.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Format input ini menjalankan konversi biasa", + "toolbar.chip_tip.ordinary_conversion.all": "Semua format input yang dapat dikonversi menjalankan konversi biasa", + "toolbar.chip_tip.ordinary_conversion.some": "Sebagian format input menjalankan konversi biasa", "toolbar.chip_tip.makes_same": "→ Format sama", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Format yang didukung: {0}", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 82452a65c..e8e0b74e1 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -73,6 +73,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} file", "donut.drop_to": "Rilascia → {0}", +"donut.ordinary_conversion.single": "Questo file usa una conversione normale", +"donut.ordinary_conversion.all": "Tutti i file convertibili usano una conversione normale", +"donut.ordinary_conversion.some": "Alcuni file usano una conversione normale", "donut.pick_in_group": "Spostati verso l'esterno per scegliere", "donut.group_empty": "Nessun preset rimasto in questo gruppo. Modificalo in Impostazioni → Barra donut", "donut.click_to": "Clic → {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "Le altre combinazioni di file aprono “{0}”.", "toolbar.mixed_others_self": "Non è rimasto nessun profilo misto senza selezioni, quindi questo viene usato anche per ogni altra combinazione.", "toolbar.chip_tip.makes": "→ {0} · {1}", +"toolbar.chip_tip.ordinary_conversion.single": "Questo formato di ingresso usa una conversione normale", +"toolbar.chip_tip.ordinary_conversion.all": "Tutti i formati di ingresso convertibili usano una conversione normale", +"toolbar.chip_tip.ordinary_conversion.some": "Alcuni formati di ingresso usano una conversione normale", "toolbar.chip_tip.makes_same": "→ Stesso formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formati accettati: {0}", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 50ff2919f..f7afcf886 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0}ファイル", "donut.drop_to": "ドロップ → {0}", + "donut.ordinary_conversion.single": "このファイルは通常の変換として実行されます", + "donut.ordinary_conversion.all": "変換可能なファイルはすべて通常の変換として実行されます", + "donut.ordinary_conversion.some": "一部のファイルは通常の変換として実行されます", "donut.pick_in_group": "外側に動かして選択", "donut.group_empty": "このグループにプリセットがありません。「設定」→「ドーナツメニュー」で編集できます", "donut.click_to": "クリック → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "ほかの形式の組み合わせには「{0}」が使われます。", "toolbar.mixed_others_self": "何も選択されていない複数形式用プロファイルがほかにないため、このプロファイルが残りすべての組み合わせにも使われます。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "この入力形式は通常の変換として実行されます", + "toolbar.chip_tip.ordinary_conversion.all": "変換可能な入力形式はすべて通常の変換として実行されます", + "toolbar.chip_tip.ordinary_conversion.some": "一部の入力形式は通常の変換として実行されます", "toolbar.chip_tip.makes_same": "→ 同じ形式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "対応形式: {0}", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 0637320af..47220f0d2 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -78,6 +78,9 @@ "donut.files_same": "{1} {0}개", "donut.files_mixed": "파일 {0}개", "donut.drop_to": "놓으면 {0}(으)로", + "donut.ordinary_conversion.single": "이 파일은 일반 변환으로 실행돼요", + "donut.ordinary_conversion.all": "변환 가능한 파일은 모두 일반 변환으로 실행돼요", + "donut.ordinary_conversion.some": "일부 파일은 일반 변환으로 실행돼요", "donut.pick_in_group": "바깥으로 옮겨 고르세요", "donut.group_empty": "묶음에 남은 프리셋 없음 (설정 → 원형 툴바에서 편집)", "donut.click_to": "클릭하면 {0}(으)로", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "그 밖의 혼합 선택에는 “{0}”이(가) 열려요.", "toolbar.mixed_others_self": "아무것도 체크하지 않은 혼합 프로필이 없어서 그 밖의 혼합 선택에도 이 프로필이 쓰여요.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "이 입력 형식은 일반 변환으로 실행돼요", + "toolbar.chip_tip.ordinary_conversion.all": "변환 가능한 입력 형식은 모두 일반 변환으로 실행돼요", + "toolbar.chip_tip.ordinary_conversion.some": "일부 입력 형식은 일반 변환으로 실행돼요", "toolbar.chip_tip.makes_same": "→ 같은 형식", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "받을 수 있는 형식 {0}개", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index c21995118..5a4e42040 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} फाइलहरू", "donut.drop_to": "छाड्नुहोस् → {0}", + "donut.ordinary_conversion.single": "यो फाइल सामान्य रूपान्तरणको रूपमा चल्छ", + "donut.ordinary_conversion.all": "रूपान्तरण गर्न मिल्ने सबै फाइल सामान्य रूपान्तरणको रूपमा चल्छन्", + "donut.ordinary_conversion.some": "केही फाइल सामान्य रूपान्तरणको रूपमा चल्छन्", "donut.pick_in_group": "छनोट गर्न बाहिरतर्फ लैजानुहोस्", "donut.group_empty": "यस समूहमा कुनै प्रिसेट बाँकी छैन। यसलाई सेटिङहरू → डोनट टुलबारमा सम्पादन गर्नुहोस्", "donut.click_to": "क्लिक गर्नुहोस् → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "फाइलहरूको अन्य मिश्रणहरूले “{0}” खोल्छन्।", "toolbar.mixed_others_self": "कुनै पनि अनचेक गरिएको मिश्रित प्रोफाइल बाँकी छैन, त्यसैले यो प्रोफाइल हरेक अन्य मिश्रणका लागि पनि प्रयोग हुन्छ।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "यो इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छ", + "toolbar.chip_tip.ordinary_conversion.all": "रूपान्तरण गर्न मिल्ने सबै इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्", + "toolbar.chip_tip.ordinary_conversion.some": "केही इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्", "toolbar.chip_tip.makes_same": "→ उही ढाँचा", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "समर्थित ढाँचाहरू: {0}", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 5f4c13220..3a1e6f4f2 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "Pliki: {0}", "donut.drop_to": "Upuść → {0}", + "donut.ordinary_conversion.single": "Ten plik jest konwertowany zwyczajnie", + "donut.ordinary_conversion.all": "Wszystkie pliki możliwe do konwersji są konwertowane zwyczajnie", + "donut.ordinary_conversion.some": "Niektóre pliki są konwertowane zwyczajnie", "donut.pick_in_group": "Przesuń na zewnątrz, by wybrać", "donut.group_empty": "W tej grupie nie ma już ustawień. Edytuj ją w Ustawienia → Pasek-pierścień", "donut.click_to": "Kliknij → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Inne mieszanki plików otwierają „{0}”.", "toolbar.mixed_others_self": "Nie został żaden mieszany profil bez zaznaczeń, więc ten jest używany także dla każdej innej mieszanki.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Ten format wejściowy jest konwertowany zwyczajnie", + "toolbar.chip_tip.ordinary_conversion.all": "Wszystkie formaty wejściowe możliwe do konwersji są konwertowane zwyczajnie", + "toolbar.chip_tip.ordinary_conversion.some": "Niektóre formaty wejściowe są konwertowane zwyczajnie", "toolbar.chip_tip.makes_same": "→ Ten sam format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Obsługiwane formaty: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index dca3fc60b..815776eb7 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} arquivos", "donut.drop_to": "Solte → {0}", + "donut.ordinary_conversion.single": "Este arquivo usa uma conversão normal", + "donut.ordinary_conversion.all": "Todos os arquivos que podem ser convertidos usam uma conversão normal", + "donut.ordinary_conversion.some": "Alguns arquivos usam uma conversão normal", "donut.pick_in_group": "Mova para fora para escolher", "donut.group_empty": "Não restam predefinições neste grupo. Edite em Configurações → Menu circular", "donut.click_to": "Clique → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Outras misturas de arquivos abrem “{0}”.", "toolbar.mixed_others_self": "Não restou perfil misto sem nada marcado, então este também é usado para toda outra mistura.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal", "toolbar.chip_tip.makes_same": "→ Mesmo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos aceitos: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 56a6ed887..6c7c3d31d 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} ficheiros", "donut.drop_to": "Largar → {0}", + "donut.ordinary_conversion.single": "Este ficheiro usa uma conversão normal", + "donut.ordinary_conversion.all": "Todos os ficheiros que podem ser convertidos usam uma conversão normal", + "donut.ordinary_conversion.some": "Alguns ficheiros usam uma conversão normal", "donut.pick_in_group": "Move o ponteiro para fora para escolher", "donut.group_empty": "Este grupo já não tem predefinições. Edita-o em Definições → Barra circular", "donut.click_to": "Clicar → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "As restantes misturas de ficheiros abrem «{0}».", "toolbar.mixed_others_self": "Não resta nenhum perfil misto sem opções selecionadas, pelo que este também é utilizado para todas as outras misturas.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal", "toolbar.chip_tip.makes_same": "→ Mesmo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos aceites: {0}", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7a8fb4b0f..f62790e46 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "Файлов: {0}", "donut.drop_to": "Отпустите → {0}", + "donut.ordinary_conversion.single": "Этот файл обрабатывается как обычная конвертация", + "donut.ordinary_conversion.all": "Все преобразуемые файлы — обычная конвертация", + "donut.ordinary_conversion.some": "Некоторые файлы — обычная конвертация", "donut.pick_in_group": "Ведите наружу, чтобы выбрать", "donut.group_empty": "В этой группе не осталось пресетов. Настройте её в разделе «Настройки → Круговое меню»", "donut.click_to": "Щёлкните → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Остальные смеси файлов открывают «{0}».", "toolbar.mixed_others_self": "Не осталось смешанного профиля без отметок, поэтому этот используется и для всех остальных смесей.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Этот входной формат обрабатывается как обычная конвертация", + "toolbar.chip_tip.ordinary_conversion.all": "Все конвертируемые входные форматы обрабатываются как обычная конвертация", + "toolbar.chip_tip.ordinary_conversion.some": "Некоторые входные форматы обрабатываются как обычная конвертация", "toolbar.chip_tip.makes_same": "→ Тот же формат", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Поддерживаемые форматы: {0}", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index d4e85e791..8cb1e96f2 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} ไฟล์", "donut.drop_to": "วาง → {0}", + "donut.ordinary_conversion.single": "ไฟล์นี้ใช้การแปลงตามปกติ", + "donut.ordinary_conversion.all": "ไฟล์ทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ", + "donut.ordinary_conversion.some": "ไฟล์บางไฟล์ใช้การแปลงตามปกติ", "donut.pick_in_group": "เลื่อนออกด้านนอกเพื่อเลือก", "donut.group_empty": "ไม่มีพรีเซ็ตเหลือในกลุ่มนี้ แก้ไขได้ในการตั้งค่า → แถบโดนัท", "donut.click_to": "คลิก → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "ไฟล์รวมประเภทแบบอื่นๆ จะเปิด “{0}”", "toolbar.mixed_others_self": "ไม่มีโปรไฟล์ไฟล์รวมอันอื่นที่ไม่ได้เลือกอะไรไว้ ดังนั้นโปรไฟล์นี้จะถูกใช้กับไฟล์รวมแบบอื่นๆ ทั้งหมดด้วย", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "รูปแบบข้อมูลเข้านี้ใช้การแปลงตามปกติ", + "toolbar.chip_tip.ordinary_conversion.all": "รูปแบบข้อมูลเข้าทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ", + "toolbar.chip_tip.ordinary_conversion.some": "รูปแบบข้อมูลเข้าบางรูปแบบใช้การแปลงตามปกติ", "toolbar.chip_tip.makes_same": "→ รูปแบบเดียวกัน", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "รูปแบบที่รองรับ: {0}", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 551990948..379053df6 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} dosya", "donut.drop_to": "Bırak → {0}", + "donut.ordinary_conversion.single": "Bu dosya normal dönüştürme olarak çalışır", + "donut.ordinary_conversion.all": "Dönüşebilen tüm dosyalar normal dönüşümle işlenir", + "donut.ordinary_conversion.some": "Bazı dosyalar normal dönüştürme olarak çalışır", "donut.pick_in_group": "Seçmek için dışa doğru ilerleyin", "donut.group_empty": "Bu grupta ön ayar kalmadı. Ayarlar → Halka araç çubuğu bölümünden düzenleyin", "donut.click_to": "Tıkla → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Diğer karışık dosya seçimleri “{0}” profilini açar.", "toolbar.mixed_others_self": "Hiçbir karışık profil tamamen işaretsiz kalmadığı için bu profil diğer tüm karışık seçimlerde de kullanılır.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Bu girdi biçimi normal dönüştürme olarak çalışır", + "toolbar.chip_tip.ordinary_conversion.all": "Dönüştürülebilen tüm girdi biçimleri normal dönüştürme olarak çalışır", + "toolbar.chip_tip.ordinary_conversion.some": "Bazı girdi biçimleri normal dönüştürme olarak çalışır", "toolbar.chip_tip.makes_same": "→ Aynı biçim", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Desteklenen biçimler: {0}", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 3e9d4f700..2b2b4f4ad 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} tệp", "donut.drop_to": "Thả → {0}", + "donut.ordinary_conversion.single": "Tệp này dùng chuyển đổi thông thường", + "donut.ordinary_conversion.all": "Tất cả tệp có thể chuyển đổi đều dùng chuyển đổi thông thường", + "donut.ordinary_conversion.some": "Một số tệp dùng chuyển đổi thông thường", "donut.pick_in_group": "Di chuyển ra ngoài để chọn", "donut.group_empty": "Nhóm này không còn cấu hình sẵn nào. Hãy chỉnh sửa trong Cài đặt → Thanh công cụ vòng tròn", "donut.click_to": "Nhấp → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Các tổ hợp tệp khác sẽ mở “{0}”.", "toolbar.mixed_others_self": "Không còn hồ sơ hỗn hợp nào không chọn mục nào, nên hồ sơ này cũng được dùng cho mọi tổ hợp khác.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Định dạng đầu vào này dùng chuyển đổi thông thường", + "toolbar.chip_tip.ordinary_conversion.all": "Tất cả định dạng đầu vào có thể chuyển đổi đều dùng chuyển đổi thông thường", + "toolbar.chip_tip.ordinary_conversion.some": "Một số định dạng đầu vào dùng chuyển đổi thông thường", "toolbar.chip_tip.makes_same": "→ Cùng định dạng", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Định dạng được nhận: {0}", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 756c675cd..25ffc6e12 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} 个 {1}", "donut.files_mixed": "{0} 个文件", "donut.drop_to": "松开 → {0}", + "donut.ordinary_conversion.single": "此文件将进行普通转换", + "donut.ordinary_conversion.all": "所有可转换的文件都将进行普通转换", + "donut.ordinary_conversion.some": "部分文件将进行普通转换", "donut.pick_in_group": "向外移动以选择", "donut.group_empty": "此组中已没有预设,请在“设置 → 环形工具栏”中编辑", "donut.click_to": "点击 → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "其他混合选择会打开“{0}”。", "toolbar.mixed_others_self": "没有未勾选任何项的混合配置,因此其他混合选择也会使用此配置。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "此输入格式将进行普通转换", + "toolbar.chip_tip.ordinary_conversion.all": "所有可转换的输入格式都将进行普通转换", + "toolbar.chip_tip.ordinary_conversion.some": "部分输入格式将进行普通转换", "toolbar.chip_tip.makes_same": "→ 相同格式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "支持的格式:{0}", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 7263329bf..f43f30c31 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} 個 {1}", "donut.files_mixed": "{0} 個檔案", "donut.drop_to": "放開 → {0}", + "donut.ordinary_conversion.single": "此檔案將執行一般轉換", + "donut.ordinary_conversion.all": "所有可轉換的檔案都將執行一般轉換", + "donut.ordinary_conversion.some": "部分檔案將執行一般轉換", "donut.pick_in_group": "向外移動以選擇", "donut.group_empty": "此群組中已沒有預設,請在「設定 → 環形工具列」中編輯", "donut.click_to": "點擊 → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "其他混合選取會開啟「{0}」。", "toolbar.mixed_others_self": "沒有未勾選任何項目的混合設定檔,因此其他混合選取也會使用此設定檔。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "此輸入格式將執行一般轉換", + "toolbar.chip_tip.ordinary_conversion.all": "所有可轉換的輸入格式都將執行一般轉換", + "toolbar.chip_tip.ordinary_conversion.some": "部分輸入格式將執行一般轉換", "toolbar.chip_tip.makes_same": "→ 相同格式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "支援的格式:{0}", diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 3a95641d2..9c9d9985b 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -44,6 +44,9 @@ public sealed class DonutItem /// Tooltip shown for disabled slices. public string? DisabledReason { get; init; } + /// A runnable slice's operation differs from its name, shown in the hub, not as an error. + public string? OrdinaryConversionNotice { get; init; } + /// /// A group slice: converts nothing itself; hovering it fans these out in a small ring outside the donut, where they /// are clicked or dropped on like ordinary slices. @@ -89,6 +92,8 @@ public sealed partial class DonutMenu : Control AvaloniaProperty.Register(nameof(CenterTitle)); public static readonly StyledProperty CenterSubtitleProperty = AvaloniaProperty.Register(nameof(CenterSubtitle)); + public static readonly StyledProperty CenterNoticeProperty = + AvaloniaProperty.Register(nameof(CenterNotice)); public static readonly StyledProperty IsEditModeProperty = AvaloniaProperty.Register(nameof(IsEditMode)); public static readonly StyledProperty AllowContextEditProperty = @@ -170,7 +175,7 @@ public sealed partial class DonutMenu : Control static DonutMenu() { AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty, GroupRingSizeProperty); - AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, + AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, CenterNoticeProperty, IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty, @@ -198,6 +203,7 @@ public DonutMenu() public double SliceOpacity { get => GetValue(SliceOpacityProperty); set => SetValue(SliceOpacityProperty, value); } public string? CenterTitle { get => GetValue(CenterTitleProperty); set => SetValue(CenterTitleProperty, value); } public string? CenterSubtitle { get => GetValue(CenterSubtitleProperty); set => SetValue(CenterSubtitleProperty, value); } + public string? CenterNotice { get => GetValue(CenterNoticeProperty); set => SetValue(CenterNoticeProperty, value); } /// Settings editor mode: shows ✎ on hover, raises drag and edit events instead of invoking slices. public bool IsEditMode { get => GetValue(IsEditModeProperty); set => SetValue(IsEditModeProperty, value); } @@ -1058,12 +1064,21 @@ private void DrawCenter(DrawingContext context, Point center, double r1, Typefac using var scaled = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(center.X, center.Y)); var maxWidth = radius * 1.6 / scale; - var title = CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); - var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); + var notice = !string.IsNullOrEmpty(CenterNotice); + var title = notice + ? CachedText(CenterNotice!, regular, 11.5, Foreground, maxWidth, maxLines: int.MaxValue) + : CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); + var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, + maxWidth, maxLines: notice ? int.MaxValue : 2); // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons. var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap; var hint = string.IsNullOrEmpty(CenterHint) ? null : CachedText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85 - hintIcons); var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); + // The notice replaces the file count. Keep both it and the action hint complete, including in a small hub. + var width = Math.Max(Math.Max(title.Width, subtitle.Width), hint is null ? 0 : hint.Width + hintIcons); + var fitScale = notice ? Math.Min(1, radius * 1.9 / scale / Math.Sqrt(width * width + total * total)) : 1; + using var fit = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) + * Matrix.CreateScale(fitScale, fitScale) * Matrix.CreateTranslation(center.X, center.Y)); var y = center.Y - total / 2; DrawCentered(context, title, center.X, y); y += title.Height; diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 18734c0f0..7a3b74354 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -9,6 +9,9 @@ namespace Filee.App.Services; public enum PresetUse { Usable, NeedsPackage, Unusable } +/// Whether the agreed extraction presets convert usable inputs without their named operation. +public enum OrdinaryConversion { None, All, Some } + /// Why a preset can or cannot run; usable presets have no reason. public readonly record struct PresetJudgement(PresetUse Use, string? Reason); @@ -63,6 +66,24 @@ public IReadOnlyList UsableFormats(Preset preset, IReadOnlyListCounts only runnable inputs; an envelope's operation is unknown until its file is opened. + public OrdinaryConversion OrdinaryConversionFor(Preset preset, IReadOnlyList formats) + { + Func? asNamed = preset.Id switch + { + "extract-frames-png" or "extract-frames-jpg" => preset.SavesFramesOf, + "extract-motion" => preset.ExtractsMotionOf, + _ => null, + }; + if (asNamed is null) + return OrdinaryConversion.None; + var usable = UsableFormats(preset, formats); + var ordinary = usable.Count(format => !asNamed(format.Id) + && !_catalog.All.OfType().Any(opener => opener.Opens(format.Id))); + return ordinary == 0 ? OrdinaryConversion.None + : ordinary == usable.Count ? OrdinaryConversion.All : OrdinaryConversion.Some; + } + private EnginePackageState? PackageEnabling(Func possible, Func plannerFor) => _packages.FirstOrDefault(p => !p.IsInstalled && possible(plannerFor(p))); diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index 04e178fc4..0535c1c66 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -91,6 +91,8 @@ internal static readonly (PaletteTab Id, string LabelKey, FormatCategory[] Categ private sealed class PaletteJudging { public Dictionary Judged { get; } = []; + public Dictionary Notices { get; } = []; + public IReadOnlyList Formats { get; set; } = []; public PresetAvailability.PaletteSession? Session { get; set; } } @@ -784,6 +786,14 @@ private void Commit() if (!_tipsByPreset.TryGetValue(preset.Id, out var tip)) _tipsByPreset[preset.Id] = tip = new PresetFormatTip(preset, _availability, _catalog.CreatePlanner, _loc); + _judgingByProfile.TryGetValue(Profile?.Id ?? "", out var judging); + tip.OrdinaryConversionNotice = (JudgesCurrentProfile ? judging?.Notices.GetValueOrDefault(preset.Id) : null) switch + { + OrdinaryConversion.All => _loc[judging!.Formats.Count == 1 + ? "toolbar.chip_tip.ordinary_conversion.single" : "toolbar.chip_tip.ordinary_conversion.all"], + OrdinaryConversion.Some => _loc["toolbar.chip_tip.ordinary_conversion.some"], + _ => null, + }; return tip; } @@ -1002,12 +1012,16 @@ private void JudgeSome(object pass, string profileId) var pending = ShownPresets().Where(p => !p.IsGroup && (profile.Id == "folders" || !placed.Contains(p.Id)) && !judging.Judged.ContainsKey(p.Id)).ToList(); if (pending.Count > 0) { - judging.Session ??= _availability.ForPalette(profile.Id == "folders" ? [FormatRegistry.FindById(FormatRegistry.Folder)!] : profile.Extensions.Select(FormatRegistry.FindByExtension) - .OfType().DistinctBy(f => f.Id).ToList()); + if (judging.Session is null) + { + judging.Formats = profile.Id == "folders" ? [FormatRegistry.FindById(FormatRegistry.Folder)!] + : profile.Extensions.Select(FormatRegistry.FindByExtension).OfType().DistinctBy(f => f.Id).ToList(); + judging.Session = _availability.ForPalette(judging.Formats); + } var clock = Stopwatch.StartNew(); foreach (var preset in pending) { - judging.Judged[preset.Id] = JudgeOne(judging.Session, preset); + judging.Judged[preset.Id] = JudgeOne(judging, preset); JudgementObserved?.Invoke(profileId, preset.Id); if (clock.Elapsed >= JudgeSlice) break; @@ -1023,11 +1037,15 @@ private void JudgeSome(object pass, string profileId) } // A failed probe must not end this dispatcher job. Folder offers fail closed; other profiles keep their fallback. - private PresetJudgement JudgeOne(PresetAvailability.PaletteSession session, Preset preset) + private PresetJudgement JudgeOne(PaletteJudging judging, Preset preset) { + judging.Notices.Remove(preset.Id); try { - return session.Judge(preset); + var result = judging.Session!.Judge(preset); + if (result.Use == PresetUse.Usable) + judging.Notices[preset.Id] = _availability.OrdinaryConversionFor(preset, judging.Formats); + return result; } catch (Exception ex) when (ex is not OutOfMemoryException) { diff --git a/src/Filee.App/ViewModels/PresetFormatTip.cs b/src/Filee.App/ViewModels/PresetFormatTip.cs index 5a0af463f..0de4ae7cf 100644 --- a/src/Filee.App/ViewModels/PresetFormatTip.cs +++ b/src/Filee.App/ViewModels/PresetFormatTip.cs @@ -25,6 +25,7 @@ public sealed partial class PresetFormatTip( [ObservableProperty] private string? _worksWith; [ObservableProperty] private string? _anyFile; [ObservableProperty] private string? _reason; + [ObservableProperty] private string? _ordinaryConversionNotice; [ObservableProperty] private IReadOnlyList _summaryRows = []; [ObservableProperty] private IReadOnlyList _fullRows = []; [ObservableProperty] private string? _footer; diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 15a5b94a9..d31b250b4 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -18,6 +18,8 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, [ObservableProperty] private IReadOnlyList _items = []; [ObservableProperty] private string _centerTitle = ""; [ObservableProperty] private string? _centerSubtitle; + [ObservableProperty] private string? _centerNotice; + private bool _singleInput; /// The job the progress ring shows, for the download badge above it; null when the ring shows none. [ObservableProperty] private JobViewModel? _progressJob; @@ -151,6 +153,7 @@ public void LoadForConnect() IsClickMode = true; IsConnectMode = true; Files = []; + CenterNotice = null; DonutOpacity = 1; HitBackground = "Transparent"; ApplyAppearance(); @@ -186,6 +189,7 @@ public void ResetForDrag() IsClickMode = false; IsConnectMode = false; Files = []; + CenterNotice = null; ApplyAppearance(); ReserveSubRing = store.Profiles.Any(profile => HasGroups(profile.PresetIds.Concat(profile.BackPresetIds ?? []))); _loads++; @@ -207,6 +211,7 @@ public void Load(IReadOnlyList files, bool clickMode, bool backFace = fa IsClickMode = clickMode; IsConnectMode = false; Files = files; + CenterNotice = null; DonutOpacity = 1; HitBackground = "Transparent"; ApplyAppearance(); @@ -216,6 +221,7 @@ public void Load(IReadOnlyList files, bool clickMode, bool backFace = fa var folders = files.Where(Directory.Exists).ToHashSet(Filee.Core.Platform.FileSystemPaths.Comparer); var mainFiles = CompanionFiles.WithoutCompanions(files.Where(p => !folders.Contains(p)).ToList()).ToHashSet(Filee.Core.Platform.FileSystemPaths.Comparer); var counted = files.Where(p => folders.Contains(p) || mainFiles.Contains(p)).ToList(); + _singleInput = counted.Count == 1; var foldersOnly = counted.Count > 0 && counted.All(folders.Contains); var profile = (foldersOnly ? store.Profiles.FirstOrDefault(p => p.Id == "folders") : null) ?? ProfileSelector.Select(store.Profiles, counted); @@ -267,6 +273,7 @@ public bool ToggleFace() if (!CanFlip) return false; ShowFace(!IsBackFace); + CenterNotice = null; CenterSubtitle = IsClickMode ? loc["donut.close_hint"] : null; return true; } @@ -344,6 +351,12 @@ DonutItem ItemOf(Preset p, bool caption = true) IconKey = SliceIcons.For(p), Show = p.Show, Direction = p.Direction, + OrdinaryConversionNotice = reason is not null ? null : availability.OrdinaryConversionFor(p, formats) switch + { + OrdinaryConversion.All => loc[_singleInput ? "donut.ordinary_conversion.single" : "donut.ordinary_conversion.all"], + OrdinaryConversion.Some => loc["donut.ordinary_conversion.some"], + _ => null, + }, }; } @@ -375,6 +388,10 @@ DonutItem ItemOf(Preset p, bool caption = true) /// Updates the centre hint for the emphasised slice (and slice of its group's ring). public void ShowHint(int index, int sub, bool centerHot) { + var item = !centerHot && index >= 0 && index < Items.Count ? Items[index] : null; + if (item?.Children is { } ring) + item = sub >= 0 && sub < ring.Count ? ring[sub] : null; + CenterNotice = item is { IsEnabled: true, IsGroup: false } ? item.OrdinaryConversionNotice : null; if (centerHot) CenterSubtitle = loc["donut.cancel"]; else if (index >= 0 && index < Items.Count && Items[index].Children is { Count: > 0 } children && diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml index f74ebb4d9..9d54447aa 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml @@ -205,6 +205,8 @@ IsVisible="{Binding Title, Converter={x:Static ObjectConverters.IsNotNull}}" /> + format == "p7m"; + public string InnerFormat(string path, string format) => throw new NotSupportedException("No content preflight."); + } + + [AvaloniaTheory] + [InlineData("extract-frames-png", "mp4", "jpg")] + [InlineData("extract-frames-jpg", "mp4", "png")] + [InlineData("extract-motion", "jpg", "png")] + [InlineData("extract-motion", "heic", "png")] + [InlineData("extract-motion", "avif", "png")] + [InlineData("extract-motion", "livp", "png")] + public void Only_runnable_inputs_without_the_named_operation_count(string id, string named, string ordinary) + { + using var f = new Fixture(new Inputs()); + var preset = f.Preset(id); + OrdinaryConversion Notice(params string[] formats) => + f.Availability.OrdinaryConversionFor(preset, formats.Select(FormatRegistry.Get).ToList()); + + Assert.Equal(OrdinaryConversion.None, Notice(named)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary, ordinary)); + Assert.Equal(OrdinaryConversion.Some, Notice(named, ordinary)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary, "wav")); + Assert.Equal(OrdinaryConversion.None, Notice(named, "wav")); + Assert.Equal(OrdinaryConversion.None, Notice("wav")); + Assert.Equal(OrdinaryConversion.None, Notice()); + } + + [AvaloniaFact] + public void The_fixed_list_does_not_probe_other_presets() + { + using var f = new Fixture(); + var availability = new PresetAvailability(f.Catalog, f.Loc, [], () => throw new InvalidOperationException("Unexpected probe.")); + foreach (var id in new[] { "to-png", "live-photo-iphone", "motion-photo-android", "caption-video", "searchable-pdf" }) + Assert.Equal(OrdinaryConversion.None, + availability.OrdinaryConversionFor(f.Preset(id), [Fixture.Format("png")])); + } + + [AvaloniaFact] + public void Current_options_missing_packages_and_opaque_envelopes_are_not_guessed() + { + using var f = new Fixture(new Inputs()); + var frames = new Preset { Id = "extract-frames-jpg", TargetFormat = "jpg" }; + Assert.Equal(OrdinaryConversion.All, f.Availability.OrdinaryConversionFor(frames, [Fixture.Format("mp4")])); + var motion = new Preset { Id = "extract-motion", TargetFormat = "mp4" }; + Assert.Equal(OrdinaryConversion.All, f.Availability.OrdinaryConversionFor(motion, [Fixture.Format("jpg")])); + var preset = f.Preset("extract-frames-png"); + Assert.Equal(OrdinaryConversion.None, f.Availability.OrdinaryConversionFor(preset, [Fixture.Format("p7m")])); + Assert.Equal(OrdinaryConversion.Some, + f.Availability.OrdinaryConversionFor(preset, [Fixture.Format("png"), Fixture.Format("p7m")])); + + using var missing = new Fixture(); + Assert.NotNull(missing.Availability.Check(missing.Preset("extract-motion"), [Fixture.Format("png")])); + Assert.Equal(OrdinaryConversion.None, + missing.Availability.OrdinaryConversionFor(missing.Preset("extract-motion"), [Fixture.Format("png")])); + } + + private sealed class Live : IDisposable + { + public Live(Fixture f, bool click, params string[] files) + { + Motion.Configure(true, () => false); + f.Store.Profiles.Clear(); + f.Store.Profiles.Add(new ToolbarProfile + { + Id = "notice-donut", + IsFallback = true, + PresetIds = ["extract-frames-png", "extract-motion", "test-jpg", "test-wav", "notice-group"], + BackPresetIds = ["test-jpg", "extract-frames-jpg", "notice-group"], + }); + f.Store.Presets.Add(new Preset + { + Id = "notice-group", + Name = "Extraction", + Kind = PresetKind.Group, + SubPresetIds = ["extract-frames-png", "extract-motion"], + }); + Vm = new RadialViewModel(f.Store, f.Loc, f.Availability); + Vm.Load(files, click); + Vm.FitToThisDonut(); + Window = new RadialWindow { DataContext = Vm }; + Donut.ActiveIndexChanged += (_, index) => Vm.ShowHint(index, Donut.ActiveSubIndex, Donut.IsCenterHot); + Window.Show(); + Dispatcher.UIThread.RunJobs(); + } + + public RadialViewModel Vm { get; } + public RadialWindow Window { get; } + public DonutMenu Donut => Window.DonutControl; + public Point Center => new(Donut.Bounds.Width / 2, Donut.Bounds.Height / 2); + + public DonutHit PointAt(int index) + { + var at = DonutGeometry.PointAt(Center, Donut.OuterRadius * 0.7, DonutGeometry.MidAngle(index, Donut.Items!.Count)); + if (Vm.IsClickMode) + Window.MouseMove(Donut.TranslatePoint(at, Window)!.Value); + else + Donut.SetExternalHit(Donut.HitTest(at), at); + Dispatcher.UIThread.RunJobs(); + return Donut.HitTest(at); + } + + public void Dispose() + { + Window.Close(); + Motion.Configure(false, () => false); + } + } + + [AvaloniaTheory] + [InlineData(true, "single")] + [InlineData(false, "single")] + [InlineData(true, "all")] + [InlineData(false, "all")] + [InlineData(true, "some")] + [InlineData(false, "some")] + public void The_real_donut_keeps_the_action_hint_and_the_slice_runnable(bool click, string scope) + { + using var f = new Fixture(new Inputs()); + string[] files = scope switch + { + "all" => ["first.png", "second.png"], + "some" => ["clip.mp4", "photo.png"], + _ => ["photo.png"], + }; + using var live = new Live(f, click, files); + var title = live.Donut.CenterTitle; + var hit = live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion." + scope], live.Donut.CenterNotice); + Assert.Equal(f.Loc.Format(click ? "donut.click_to" : "donut.drop_to", Assert.IsType(live.Donut.Items![0].FullName)), + live.Donut.CenterSubtitle); + Assert.Equal(title, live.Donut.CenterTitle); + Assert.True(live.Donut.Items![0].IsEnabled); + Assert.True(live.Donut.Converts(hit)); + Assert.Same(f.Preset("extract-frames-png"), live.Vm.PresetAt(0, -1)); + + live.PointAt(2); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(3); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal(live.Donut.Items[3].DisabledReason, live.Donut.CenterSubtitle); + } + + [AvaloniaFact] + public void Groups_faces_cancellation_and_reuse_do_not_keep_an_old_notice() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: false, "photo.png"); + live.PointAt(4); + Assert.Null(live.Donut.CenterNotice); + live.Donut.ExpandPendingGroup(); + var at = live.Donut.SubSliceCenter(4, 0); + live.Donut.SetExternalHit(live.Donut.HitTest(at), at); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + Assert.True(live.Donut.Converts(live.Donut.HitTest(at))); + Assert.Same(f.Preset("extract-frames-png"), live.Vm.PresetAt(4, 0)); + live.Donut.CollapseGroup(); + Assert.Null(live.Donut.CenterNotice); + + live.PointAt(0); + live.Donut.SetExternalHit(new DonutHit(DonutHitKind.Center), live.Center); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal(f.Loc["donut.cancel"], live.Donut.CenterSubtitle); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + Assert.True(live.Donut.Flip()); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal("test-jpg", live.Donut.Items![0].Id); + live.PointAt(1); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + live.PointAt(2); + Assert.Null(live.Donut.CenterNotice); + live.Donut.ExpandPendingGroup(); + at = live.Donut.SubSliceCenter(2, 1); + live.Donut.SetExternalHit(live.Donut.HitTest(at), at); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + Assert.Same(f.Preset("extract-motion"), live.Vm.PresetAt(2, 1)); + live.Donut.SetExternalHit(new DonutHit(DonutHitKind.Outside), new Point(-20, -20)); + Assert.Null(live.Donut.CenterNotice); + + live.PointAt(1); + live.Vm.Load(["clip.mp4"], clickMode: false); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(0); + Assert.Null(live.Donut.CenterNotice); + live.Vm.Load(["photo.png"], clickMode: false); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Vm.ResetForDrag(); + Assert.Null(live.Donut.CenterNotice); + live.Vm.Load(["photo.png"], clickMode: false); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Vm.LoadForConnect(); + Assert.Null(live.Donut.CenterNotice); + Assert.True(live.Vm.IsConnectMode); + Assert.Equal("preset.filee_connect", Assert.Single(live.Donut.Items!).Id); + } + + [AvaloniaFact] + public void Keyboard_hover_and_flip_use_the_real_click_window() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: true, "photo.png"); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(1, live.Donut.ActiveIndex); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + live.Donut.FlipKey = "Tab"; + live.Window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(live.Vm.IsBackFace); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(1); + Assert.NotNull(live.Donut.CenterNotice); + var escaped = false; + live.Window.EscapePressed += () => escaped = true; + live.Window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(escaped); + } + + [AvaloniaFact] + public void Unknown_files_folders_and_companions_do_not_become_ordinary_inputs() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: false, "photo.png", "unknown.notice-unknown"); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.all"], live.Donut.CenterNotice); + var folder = Directory.CreateTempSubdirectory("filee-notice-"); + try + { + live.Vm.Load(["photo.png", folder.FullName], clickMode: false); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.all"], live.Donut.CenterNotice); + live.Vm.Load(new[] { "shp", "dbf", "shx" }.Select(ext => Path.Combine(folder.FullName, "map." + ext)).ToList(), + clickMode: false); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + } + finally { folder.Delete(); } + } + + [AvaloniaTheory] + [InlineData("en", false)] + [InlineData("ko", true)] + [InlineData("ru", false)] + [InlineData("es", true)] + [InlineData("ar", true)] + public void The_notice_is_actually_drawn_in_the_hub(string language, bool dark) + { + TestServices.EnsureInitialized(language); + var app = Avalonia.Application.Current!; + var before = app.RequestedThemeVariant; + var donut = new DonutMenu { Items = [new() { Id = "one", Label = "PNG" }], CenterTitle = "" }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + try + { + app.RequestedThemeVariant = dark ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light; + window.Show(); + RenderTests.Pump(); + var empty = HubPixels(donut); + donut.CenterNotice = AppHost.Get()["donut.ordinary_conversion.all"]; + RenderTests.Pump(); + Assert.False(empty.SequenceEqual(HubPixels(donut))); + donut.CenterSubtitle = AppHost.Get().Format("donut.drop_to", "PNG"); + RenderTests.Pump(); + var withAction = HubPixels(donut); + donut.CenterSubtitle = null; + RenderTests.Pump(); + Assert.False(withAction.SequenceEqual(HubPixels(donut))); + } + finally + { + window.Close(); + app.RequestedThemeVariant = before; + TestServices.EnsureInitialized("en"); + } + } + + private static byte[] HubPixels(DonutMenu donut) + { + using var bitmap = new RenderTargetBitmap(new PixelSize((int)donut.Bounds.Width, (int)donut.Bounds.Height)); + bitmap.Render(donut); + var radius = (int)(donut.OuterRadius * donut.HoleRatio - 8); + var pixels = new byte[radius * radius * 16]; + var pin = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + bitmap.CopyPixels(new PixelRect((int)donut.Bounds.Width / 2 - radius, (int)donut.Bounds.Height / 2 - radius, + radius * 2, radius * 2), pin.AddrOfPinnedObject(), pixels.Length, radius * 8); + } + finally { pin.Free(); } + return pixels; + } +} diff --git a/tests/Filee.App.Tests/PaletteChipTipModelTests.cs b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs index 1f8d16cdf..b6d9c0a20 100644 --- a/tests/Filee.App.Tests/PaletteChipTipModelTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs @@ -5,6 +5,7 @@ using Filee.App.ViewModels.Pages; using Filee.Core.Conversion; using Filee.Core.Formats; +using Filee.Core.Localization; using Filee.Core.Presets; namespace Filee.App.Tests; @@ -15,6 +16,99 @@ public class PaletteChipTipModelTests private static PresetFormatTip Tip(ToolbarPageViewModel vm, string id) => Assert.IsType(vm.Palette.Single(c => c.PresetId == id).Tip); + [AvaloniaFact] + public void Ordinary_notice_follows_the_profile_and_extensions_without_rebuilding_formats() + { + using var f = new PresetAvailabilityTests.Fixture(new OrdinaryConversionNoticeTests.Inputs()); + var other = new Filee.Core.Profiles.ToolbarProfile { Id = "notice-video", Extensions = ["mp4"], PresetIds = [] }; + f.Store.Profiles.Add(other); + using var vm = f.PageWithPalette("png"); + Dispatcher.UIThread.RunJobs(); + var originalProfile = vm.SelectedProfile; + var tip = Tip(vm, "extract-frames-jpg"); + tip.EnsureBuilt(); + var rows = tip.FullRows; + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile == other); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.SelectedProfile = originalProfile; + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + + vm.ExtensionTags!.AddCommand.Execute("mp4"); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.some"], tip.OrdinaryConversionNotice); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single(t => t.Value == "png")); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single()); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.IsMixed = true; + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.MixedExtensions!.Set(["png"], check: true); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + vm.MixedExtensions.Set(["png"], check: false); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + Assert.Same(rows, tip.FullRows); + Assert.Equal(1, tip.BuildCount); + } + + [AvaloniaFact] + public void Engine_changes_rejudge_the_notice_and_failed_probes_do_not_attach_one() + { + var inputs = new OrdinaryConversionNoticeTests.Inputs(); + using var f = new PresetAvailabilityTests.Fixture(inputs); + using (var vm = f.PageWithPalette("png")) + { + Dispatcher.UIThread.RunJobs(); + var before = Tip(vm, "extract-frames-jpg"); + Assert.NotNull(before.OrdinaryConversionNotice); + inputs.Available = f.Built.Available = false; + f.Catalog.Refresh(); + Dispatcher.UIThread.RunJobs(); + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + var unavailable = vm.VisiblePalette.Single(c => c.PresetId == "extract-frames-jpg"); + Assert.NotNull(unavailable.Reason); + Assert.IsType(unavailable.Tip); + inputs.Available = f.Built.Available = true; + f.Catalog.Refresh(); + Dispatcher.UIThread.RunJobs(); + var after = Tip(vm, "extract-frames-jpg"); + Assert.NotSame(before, after); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], after.OrdinaryConversionNotice); + } + + var profile = new Filee.Core.Profiles.ToolbarProfile { Id = "notice-failed-probe", Extensions = ["png"], PresetIds = [] }; + f.Store.Profiles.Add(profile); + var probeFails = false; + var failing = new PresetAvailability(f.Catalog, f.Loc, [], + () => probeFails ? throw new InvalidOperationException("Probe failed.") : f.Catalog.CreatePlanner()); + using var failed = new ToolbarPageViewModel(f.Store, f.Loc, AppHost.Get(), failing, f.Catalog, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + failed.SelectedProfile = failed.Profiles.Single(p => p.Profile == profile); + Dispatcher.UIThread.RunJobs(); + Assert.NotNull(Tip(failed, "extract-frames-jpg").OrdinaryConversionNotice); + probeFails = true; + failed.PresetEdited(f.Preset("extract-frames-jpg")); + Dispatcher.UIThread.RunJobs(); + Assert.Null(failed.Palette.Single(c => c.PresetId == "extract-frames-jpg").Reason); + Assert.Null(Tip(failed, "extract-frames-jpg").OrdinaryConversionNotice); + } + [AvaloniaFact] public void T1_usable_formats_match_single_format_checks_in_registry_order() { diff --git a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs index 254adbe5d..c3e842f60 100644 --- a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs @@ -116,6 +116,108 @@ private static string TextOf(TextBlock block) => block.Inlines is { Count: > 0 } ? string.Concat(inlines.OfType().Select(r => r.Text)) : block.Text ?? ""; + [AvaloniaTheory] + [InlineData("png", "single", false)] + [InlineData("png,mp4", "some", false)] + [InlineData("png,jpg", "all", false)] + [InlineData("jpg,jpeg", "single", false)] + [InlineData("mp4", null, false)] + [InlineData("", null, false)] + [InlineData("notice-unknown", null, false)] + [InlineData("png", "single", true)] + [InlineData("", null, true)] + public void Ordinary_notice_is_an_additional_row_in_a_runnable_chips_real_tip(string extensions, string? scope, bool fallback) + { + using var f = new Fixture(new OrdinaryConversionNoticeTests.Inputs()); + using var vm = f.Page(new ToolbarProfile + { + Id = "notice-tip", + Extensions = extensions.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), + IsFallback = fallback, + PresetIds = [], + }); + using var view = new View(vm); + if (extensions == "notice-unknown") + { + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + RenderTests.Pump(); + var unknown = view.Chip("extract-frames-jpg"); + var reason = ((PaletteChip)unknown.DataContext!).Reason; + Assert.NotNull(reason); + Assert.Equal(reason, View.Open(unknown).Content); + View.Close(unknown); + return; + } + var chip = view.Chip("extract-frames-jpg"); + var model = Assert.IsType(chip.DataContext); + Assert.Null(model.Reason); + Assert.True(chip.IsEnabled); + Assert.Equal(1, chip.Opacity); + var tip = View.Open(chip); + var block = tip.GetVisualDescendants().OfType().Single(b => b.Name == "TipOrdinaryConversionNotice"); + Assert.Equal(scope is not null, block.IsEffectivelyVisible); + Assert.Equal(scope is null ? null : f.Loc["toolbar.chip_tip.ordinary_conversion." + scope], block.Text); + Assert.NotNull(Read(tip).Makes); + Assert.NotNull(Read(tip).WorksWith); + Assert.NotEmpty(Read(tip).Rows); + Assert.DoesNotContain("ordinary_conversion.", string.Join(" ", Read(tip).Lines)); + View.Close(chip); + var preset = f.Preset("extract-frames-jpg"); + Assert.True(vm.Insert(preset.Id, 0)); + Assert.Contains(preset.Id, vm.SelectedProfile!.Profile.PresetIds); + } + + [AvaloniaFact] + public void An_open_ordinary_tip_updates_and_clears_when_input_extensions_change() + { + using var f = new Fixture(new OrdinaryConversionNoticeTests.Inputs()); + using var vm = f.PageWithPalette("png"); + using var view = new View(vm); + var tipModel = Assert.IsType(((PaletteChip)view.Chip("extract-frames-jpg").DataContext!).Tip); + void Check(string? scope) + { + var chip = view.Chip("extract-frames-jpg"); + var tip = View.Open(chip); + Assert.Same(tipModel, ((PaletteChip)chip.DataContext!).Tip); + var block = tip.GetVisualDescendants().OfType().Single(b => b.Name == "TipOrdinaryConversionNotice"); + Assert.Equal(scope is not null, block.IsEffectivelyVisible); + Assert.Equal(scope is null ? null : f.Loc["toolbar.chip_tip.ordinary_conversion." + scope], block.Text); + View.Close(chip); + } + + Check("single"); + vm.ExtensionTags!.AddCommand.Execute("mp4"); + RenderTests.Pump(); + Check("some"); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single(t => t.Value == "png")); + RenderTests.Pump(); + Check(null); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single()); + RenderTests.Pump(); + Check(null); + Assert.Equal(1, tipModel.BuildCount); + } + + [AvaloniaFact] + public void Folder_and_missing_engine_chips_keep_only_their_reason_tip() + { + using var f = new Fixture(); + using var vm = f.Page(new ToolbarProfile { Id = "folders", PresetIds = [] }); + using var view = new View(vm); + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + RenderTests.Pump(); + Assert.DoesNotContain(vm.Palette, c => c.PresetId == "extract-frames-jpg"); + Assert.DoesNotContain(view.Chips, c => ((PaletteChip)c.DataContext!).PresetId == "extract-frames-jpg"); + + using var ordinary = f.PageWithPalette("png"); + using var ordinaryView = new View(ordinary); + var chip = ordinaryView.Chip("extract-motion"); + var reason = ((PaletteChip)chip.DataContext!).Reason; + Assert.NotNull(reason); + Assert.Equal(reason, View.Open(chip).Content); + View.Close(chip); + } + private static Shown Read(ToolTip tip) { var blocks = tip.GetVisualDescendants().OfType().Where(b => b.IsEffectivelyVisible).ToList(); diff --git a/tests/Filee.Core.Tests/JobQueueTests.cs b/tests/Filee.Core.Tests/JobQueueTests.cs index 91f2f4d6f..a0690c081 100644 --- a/tests/Filee.Core.Tests/JobQueueTests.cs +++ b/tests/Filee.Core.Tests/JobQueueTests.cs @@ -436,6 +436,48 @@ public async Task A_refused_conversion_says_why_in_the_users_language() Assert.Equal(("error.too_many_frames", "216,000 > 100,000"), (file.ErrorKey, file.ErrorDetail)); } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task A_mixed_drop_selects_extraction_per_source_and_does_not_retry_a_refused_motion_photo(bool motion, bool refused) + { + using var dir = new TempDir(); + var from = motion ? "jpg" : "mp4"; + var target = motion ? "mp4" : "jpg"; + var named = dir.File("named." + from); + var plain = dir.File("plain.png"); + var special = new FakeConverter("extraction", new ConversionEdge(from, target, FrameSequence: !motion, MotionPhoto: motion)) + { + Failure = refused ? new ConversionRefusedException("error.no_motion_photo") : null, + }; + var ordinary = new FakeConverter("ordinary", new ConversionEdge(from, target), new ConversionEdge("png", target)); + await using var queue = new JobQueue(new ConverterCatalog([special, ordinary])); + var preset = new Preset { TargetFormat = target, Media = { SaveEveryFrame = !motion, ExtractMotionPhoto = motion } }; + + var job = await Run(queue, [named, plain], preset); + + Assert.Equal([named], special.OriginalSources); + Assert.Equal([plain], ordinary.OriginalSources); + var namedResult = job.Files.Single(f => f.SourcePath == named); + var plainResult = job.Files.Single(f => f.SourcePath == plain); + Assert.Equal(FileState.Done, plainResult.State); + Assert.EndsWith("by ordinary", File.ReadAllText(Assert.Single(plainResult.Outputs))); + if (refused) + { + Assert.Equal(JobState.CompletedWithErrors, job.State); + Assert.Equal(FileState.Failed, namedResult.State); + Assert.Equal("error.no_motion_photo", namedResult.ErrorKey); + Assert.Empty(namedResult.Outputs); + } + else + { + Assert.Equal(JobState.Completed, job.State); + Assert.Equal(FileState.Done, namedResult.State); + Assert.EndsWith("by extraction", File.ReadAllText(Assert.Single(namedResult.Outputs))); + } + } + [Fact] public async Task Multi_step_route_keeps_intermediates_out_of_the_output_folder() {