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()
{