diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index ab07dfc21..2e478d2ed 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "يأتي «{0}» أولًا ويسمح بكل الامتدادات المحددة هنا، لذا لن يُستخدم ملف التعريف هذا.",
"toolbar.mixed_others": "تفتح التحديدات المختلطة الأخرى «{0}».",
"toolbar.mixed_others_self": "لا يوجد ملف تعريف مختلط بلا امتدادات محددة، لذا يُستخدم هذا الملف أيضًا لكل مزيج آخر.",
- "tags.remove": "إزالة",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ التنسيق نفسه",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "الصيغ المقبولة: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": "، ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} · Shift لعرض الكل",
+ "toolbar.chip_tip.shift_all": "Shift لعرض الكل",
+ "toolbar.chip_tip.any_file": "أي ملف",
+ "tags.remove": "إزالة",
"presets.title": "الإعدادات المسبقة",
"presets.add": "جديد",
"presets.add_empty": "إعداد مسبق فارغ",
diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json
index 436b9cc14..bd6d41814 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "„{0}“ steht weiter oben und erlaubt alle hier ausgewählten Dateiendungen. Dieses Profil wird daher nie verwendet.",
"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.",
- "tags.remove": "Entfernen",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Gleiches Format",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Formate: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} zusätzlich · Shift zeigt alle",
+ "toolbar.chip_tip.shift_all": "Shift zeigt alle",
+ "toolbar.chip_tip.any_file": "Beliebige Datei",
+ "tags.remove": "Entfernen",
"presets.title": "Vorlagen",
"presets.add": "Neu",
"presets.add_empty": "Leere Vorlage",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 5fd3954a9..f41855faa 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "“{0}” comes first and allows every extension checked here, so this profile is never used.",
"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.",
- "tags.remove": "Remove",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Same format",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Accepted formats: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} more · Shift to show all",
+ "toolbar.chip_tip.shift_all": "Shift to show all",
+ "toolbar.chip_tip.any_file": "Any file",
+ "tags.remove": "Remove",
"presets.title": "Presets",
"presets.add": "New",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index ae43cfefc..18f3d58d9 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "«{0}» va antes y admite todas las extensiones marcadas aquí, así que este perfil no se usa nunca.",
"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.",
- "tags.remove": "Quitar",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Mismo formato",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Formatos admitidos: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} más · Shift para ver todos",
+ "toolbar.chip_tip.shift_all": "Shift para ver todos",
+ "toolbar.chip_tip.any_file": "Cualquier archivo",
+ "tags.remove": "Quitar",
"presets.title": "Ajustes preestablecidos",
"presets.add": "Nuevo",
"presets.add_empty": "Ajuste vacío",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index d91360e12..c5d243b5e 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "« {0} » est placé avant et accepte toutes les extensions cochées ici : ce profil n'est donc jamais utilisé.",
"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.",
- "tags.remove": "Retirer",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Même format",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Formats acceptés : {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} en plus · Shift pour tout afficher",
+ "toolbar.chip_tip.shift_all": "Shift pour tout afficher",
+ "toolbar.chip_tip.any_file": "Tout fichier",
+ "tags.remove": "Retirer",
"presets.title": "Préréglages",
"presets.add": "Nouveau",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index fa612926b..a3e798a5e 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "“{0}” पहले आती है और यहाँ चुने गए हर एक्सटेंशन को अनुमति देती है, इसलिए यह प्रोफ़ाइल कभी इस्तेमाल नहीं होगी।",
"toolbar.mixed_others": "फ़ाइलों के अन्य मिश्रण “{0}” खोलते हैं।",
"toolbar.mixed_others_self": "कोई मिश्रित प्रोफ़ाइल बिना चयन के नहीं बची है, इसलिए यह प्रोफ़ाइल हर अन्य मिश्रण के लिए भी इस्तेमाल होती है।",
- "tags.remove": "हटाएँ",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ वही फ़ॉर्मैट",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "स्वीकार्य फ़ॉर्मैट: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} अतिरिक्त · Shift से सभी दिखाएँ",
+ "toolbar.chip_tip.shift_all": "Shift से सभी दिखाएँ",
+ "toolbar.chip_tip.any_file": "कोई भी फ़ाइल",
+ "tags.remove": "हटाएँ",
"presets.title": "प्रीसेट",
"presets.add": "नया",
"presets.add_empty": "खाली प्रीसेट",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index 7fadc185e..b6960a369 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "「{0}」の方が一覧で先にあり、ここで選択したすべての拡張子を含むため、このプロファイルは使われません。",
"toolbar.mixed_others": "ほかの形式の組み合わせには「{0}」が使われます。",
"toolbar.mixed_others_self": "何も選択されていない複数形式用プロファイルがほかにないため、このプロファイルが残りすべての組み合わせにも使われます。",
- "tags.remove": "削除",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ 同じ形式",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "対応形式: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": "、",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} 件 · Shift ですべて表示",
+ "toolbar.chip_tip.shift_all": "Shift ですべて表示",
+ "toolbar.chip_tip.any_file": "すべてのファイル",
+ "tags.remove": "削除",
"presets.title": "プリセット",
"presets.add": "新規",
"presets.add_empty": "空のプリセット",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index e81e22da1..09604ddae 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "목록에서 먼저인 “{0}”이(가) 여기서 체크한 확장자를 모두 허용해서 이 프로필은 쓰이지 않아요.",
"toolbar.mixed_others": "그 밖의 혼합 선택에는 “{0}”이(가) 열려요.",
"toolbar.mixed_others_self": "아무것도 체크하지 않은 혼합 프로필이 없어서 그 밖의 혼합 선택에도 이 프로필이 쓰여요.",
- "tags.remove": "빼기",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ 같은 형식",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "받을 수 있는 형식 {0}개",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} 외 {1}개",
+ "toolbar.chip_tip.more_shift": "그 밖에 {0}개 형식 · Shift로 전부 보기",
+ "toolbar.chip_tip.shift_all": "Shift로 전부 보기",
+ "toolbar.chip_tip.any_file": "모든 파일",
+ "tags.remove": "빼기",
"presets.title": "프리셋",
"presets.add": "새로 만들기",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index c258c7e9f..dade701b1 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "“{0}” पहिले आउँछ र यहाँ जाँच गरिएका सबै एक्सटेन्सनहरूलाई अनुमति दिन्छ, त्यसैले यो प्रोफाइल कहिल्यै प्रयोग हुँदैन।",
"toolbar.mixed_others": "फाइलहरूको अन्य मिश्रणहरूले “{0}” खोल्छन्।",
"toolbar.mixed_others_self": "कुनै पनि अनचेक गरिएको मिश्रित प्रोफाइल बाँकी छैन, त्यसैले यो प्रोफाइल हरेक अन्य मिश्रणका लागि पनि प्रयोग हुन्छ।",
- "tags.remove": "ट्याग हटाउनुहोस्",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ उही ढाँचा",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "समर्थित ढाँचाहरू: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} थप · Shift ले सबै देखाउँछ",
+ "toolbar.chip_tip.shift_all": "Shift ले सबै देखाउँछ",
+ "toolbar.chip_tip.any_file": "कुनै पनि फाइल",
+ "tags.remove": "ट्याग हटाउनुहोस्",
"presets.title": "प्रिसेटहरू",
"presets.add": "नयाँ",
"presets.add_empty": "खाली प्रिसेट",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 8ce69b4b3..0c7af06e2 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "„{0}” jest pierwszy i dopuszcza każde zaznaczone tu rozszerzenie, więc ten profil nigdy nie jest używany.",
"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.",
- "tags.remove": "Usuń",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Ten sam format",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Obsługiwane formaty: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} dodatkowo · Shift pokazuje wszystkie",
+ "toolbar.chip_tip.shift_all": "Shift pokazuje wszystkie",
+ "toolbar.chip_tip.any_file": "Dowolny plik",
+ "tags.remove": "Usuń",
"presets.title": "Ustawienia wstępne",
"presets.add": "Nowe",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index 1292206f6..88d47fdae 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "“{0}” vem primeiro e permite toda extensão marcada aqui, então este perfil nunca é usado.",
"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.",
- "tags.remove": "Remover",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Mesmo formato",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Formatos aceitos: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} a mais · Shift para mostrar todos",
+ "toolbar.chip_tip.shift_all": "Shift para mostrar todos",
+ "toolbar.chip_tip.any_file": "Qualquer arquivo",
+ "tags.remove": "Remover",
"presets.title": "Predefinições",
"presets.add": "Nova",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index 48563251f..f89ce25c5 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "«{0}» aparece primeiro e abrange todas as extensões selecionadas aqui, pelo que este perfil nunca é utilizado.",
"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.",
- "tags.remove": "Remover",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Mesmo formato",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Formatos aceites: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} a mais · Shift para mostrar todos",
+ "toolbar.chip_tip.shift_all": "Shift para mostrar todos",
+ "toolbar.chip_tip.any_file": "Qualquer ficheiro",
+ "tags.remove": "Remover",
"presets.title": "Predefinições",
"presets.add": "Nova",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 62243b38d..47f36cb2d 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "«{0}» стоит выше и допускает все отмеченные здесь расширения, поэтому этот профиль никогда не используется.",
"toolbar.mixed_others": "Остальные смеси файлов открывают «{0}».",
"toolbar.mixed_others_self": "Не осталось смешанного профиля без отметок, поэтому этот используется и для всех остальных смесей.",
- "tags.remove": "Убрать",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Тот же формат",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Поддерживаемые форматы: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} дополнительно · Shift — показать все",
+ "toolbar.chip_tip.shift_all": "Shift — показать все",
+ "toolbar.chip_tip.any_file": "Любой файл",
+ "tags.remove": "Убрать",
"presets.title": "Пресеты",
"presets.add": "Новый",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index dcadcb3e8..ff9466b61 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "“{0}” อยู่ก่อนหน้าและครอบคลุมทุกนามสกุลที่เลือกไว้ที่นี่ โปรไฟล์นี้จึงจะไม่ได้ถูกนำมาใช้",
"toolbar.mixed_others": "ไฟล์รวมประเภทแบบอื่นๆ จะเปิด “{0}”",
"toolbar.mixed_others_self": "ไม่มีโปรไฟล์ไฟล์รวมอันอื่นที่ไม่ได้เลือกอะไรไว้ ดังนั้นโปรไฟล์นี้จะถูกใช้กับไฟล์รวมแบบอื่นๆ ทั้งหมดด้วย",
- "tags.remove": "ลบออก",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ รูปแบบเดียวกัน",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "รูปแบบที่รองรับ: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} เพิ่มเติม · กด Shift เพื่อแสดงทั้งหมด",
+ "toolbar.chip_tip.shift_all": "กด Shift เพื่อแสดงทั้งหมด",
+ "toolbar.chip_tip.any_file": "ไฟล์ใดก็ได้",
+ "tags.remove": "ลบออก",
"presets.title": "พรีเซ็ต",
"presets.add": "สร้างใหม่",
"presets.add_empty": "พรีเซ็ตว่าง",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index 88cf74306..ad1043c97 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -596,7 +596,17 @@
"toolbar.mixed_covered": "“{0}” önce geliyor ve burada işaretli tüm uzantıları kabul ediyor; bu profil hiç kullanılmaz.",
"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.",
- "tags.remove": "Kaldır",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ Aynı biçim",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "Desteklenen biçimler: {0}",
+ "toolbar.chip_tip.kind": "{0} ({1})",
+ "toolbar.chip_tip.name_separator": ", ",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} ek · Shift tümünü gösterir",
+ "toolbar.chip_tip.shift_all": "Shift tümünü gösterir",
+ "toolbar.chip_tip.any_file": "Herhangi bir dosya",
+ "tags.remove": "Kaldır",
"presets.title": "Ön ayarlar",
"presets.add": "Yeni",
"presets.add_empty": "Boş ön ayar",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 9b8cefcdd..e0b384c6f 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "靠前的“{0}”允许此处勾选的所有扩展名,因此不会使用此配置。",
"toolbar.mixed_others": "其他混合选择会打开“{0}”。",
"toolbar.mixed_others_self": "没有未勾选任何项的混合配置,因此其他混合选择也会使用此配置。",
- "tags.remove": "移除",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ 相同格式",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "支持的格式:{0}",
+ "toolbar.chip_tip.kind": "{0}({1})",
+ "toolbar.chip_tip.name_separator": "、",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} 项 · 按 Shift 显示全部",
+ "toolbar.chip_tip.shift_all": "按 Shift 显示全部",
+ "toolbar.chip_tip.any_file": "任意文件",
+ "tags.remove": "移除",
"presets.title": "预设",
"presets.add": "新建",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index aa55dc24b..ae6a38060 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -610,7 +610,17 @@
"toolbar.mixed_covered": "排在前面的「{0}」允許此處勾選的所有副檔名,因此不會使用此設定檔。",
"toolbar.mixed_others": "其他混合選取會開啟「{0}」。",
"toolbar.mixed_others_self": "沒有未勾選任何項目的混合設定檔,因此其他混合選取也會使用此設定檔。",
- "tags.remove": "移除",
+ "toolbar.chip_tip.makes": "→ {0} · {1}",
+ "toolbar.chip_tip.makes_same": "→ 相同格式",
+ "toolbar.chip_tip.makes_name": "→ {0}",
+ "toolbar.chip_tip.works_with": "支援的格式:{0}",
+ "toolbar.chip_tip.kind": "{0}({1})",
+ "toolbar.chip_tip.name_separator": "、",
+ "toolbar.chip_tip.names_more": "{0} +{1}",
+ "toolbar.chip_tip.more_shift": "+{0} 項 · 按 Shift 顯示全部",
+ "toolbar.chip_tip.shift_all": "按 Shift 顯示全部",
+ "toolbar.chip_tip.any_file": "任何檔案",
+ "tags.remove": "移除",
"presets.title": "預設",
"presets.add": "新增",
diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs
index 687c59bd5..b4ee3947b 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -40,6 +40,23 @@ internal PresetAvailability(ConverterCatalog catalog, ILocalizer loc, IReadOnlyL
Evaluate(preset, formats, _catalog.CreatePlanner,
possible => PackageEnabling(possible, p => _catalog.CreatePlanner(p.Package.ConverterIds))).Reason;
+ /// Formats for which this preset is usable, in the input order.
+ public IReadOnlyList UsableFormats(Preset preset, IReadOnlyList formats)
+ {
+ RoutePlanner? installed = null;
+ var packagePlanners = new Dictionary();
+ RoutePlanner Installed() => installed ??= _catalog.CreatePlanner();
+ EnginePackageState? Enabling(Func possible) =>
+ PackageEnabling(possible, p => packagePlanners.TryGetValue(p, out var planner)
+ ? planner : packagePlanners[p] = _catalog.CreatePlanner(p.Package.ConverterIds));
+
+ var usable = new List();
+ foreach (var format in formats)
+ if (Evaluate(preset, [format], Installed, Enabling).Use == PresetUse.Usable)
+ usable.Add(format);
+ return usable;
+ }
+
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 5a8d90148..bdc6354f6 100644
--- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
@@ -60,6 +60,7 @@ public sealed record PaletteChip(string PresetId, string Label, string? Caption,
{
public bool IsDimmed => Use != PresetUse.Usable;
public bool CanAdd => Use != PresetUse.Unusable;
+ public object? Tip { get; init; }
}
public sealed partial class ToolbarPageViewModel : ObservableObject, IDisposable
@@ -92,6 +93,7 @@ private sealed class PaletteJudging
}
private readonly Dictionary _judgingByProfile = [];
+ private readonly Dictionary _tipsByPreset = [];
private object? _pass;
private bool _disposed;
private bool _savingOwnLibrary;
@@ -142,6 +144,7 @@ public void Dispose()
_store.LibraryChanged -= OnLibraryChanged;
StopJudging();
_judgingByProfile.Clear();
+ _tipsByPreset.Clear();
}
private void StopJudging()
@@ -153,7 +156,10 @@ private void ForgetJudgements(string? profileId = null)
{
StopJudging();
if (profileId is null)
+ {
_judgingByProfile.Clear();
+ _tipsByPreset.Clear();
+ }
else
_judgingByProfile.Remove(profileId);
}
@@ -464,6 +470,7 @@ public async Task EditChipAsync(string presetId)
internal void PresetEdited(Preset preset)
{
StopJudging();
+ _tipsByPreset.Remove(preset.Id);
foreach (var judging in _judgingByProfile.Values)
judging.Judged.Remove(preset.Id);
Commit();
@@ -586,6 +593,18 @@ private void Commit()
Rebuild();
}
+ private object? TipFor(Preset preset, PresetUse use, string? reason)
+ {
+ if (preset.IsGroup)
+ return null;
+ if (use != PresetUse.Usable)
+ return reason;
+ if (!_tipsByPreset.TryGetValue(preset.Id, out var tip))
+ _tipsByPreset[preset.Id] = tip =
+ new PresetFormatTip(preset, _availability, _catalog.CreatePlanner, _loc);
+ return tip;
+ }
+
/// Rebuilds the donut slices (of the face being edited) and the palette from the model.
private void Rebuild()
{
@@ -640,7 +659,8 @@ private void Rebuild()
// "GIF GIF" says nothing twice: only show the format when the name doesn't already.
if (caption is not null && label.Contains(caption, StringComparison.OrdinalIgnoreCase))
caption = null;
- var chip = new PaletteChip(preset.Id, label, caption, reason, use);
+ var chip = new PaletteChip(preset.Id, label, caption, reason, use)
+ { Tip = TipFor(preset, use, reason) };
if (use != PresetUse.Unusable)
Palette.Add(chip);
if ((query.Length == 0 && use != PresetUse.Unusable)
@@ -730,7 +750,8 @@ private void RefreshVisiblePalette()
if (query.Length > 0 && !label.Contains(query, StringComparison.CurrentCultureIgnoreCase)
&& caption?.Contains(query, StringComparison.CurrentCultureIgnoreCase) != true)
continue;
- candidates.Add((new PaletteChip(preset.Id, label, caption, judgement.Reason, judgement.Use),
+ candidates.Add((new PaletteChip(preset.Id, label, caption, judgement.Reason, judgement.Use)
+ { Tip = TipFor(preset, judgement.Use, judgement.Reason) },
preset.IsGroup ? PaletteTab.All : TabOf(preset), preset.IsGroup));
}
RefreshVisiblePalette(candidates);
diff --git a/src/Filee.App/ViewModels/PresetFormatTip.cs b/src/Filee.App/ViewModels/PresetFormatTip.cs
new file mode 100644
index 000000000..50ae037e9
--- /dev/null
+++ b/src/Filee.App/ViewModels/PresetFormatTip.cs
@@ -0,0 +1,138 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Filee.App.Services;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+
+namespace Filee.App.ViewModels;
+
+public sealed record PresetFormatTipRow(string Kind, string Names);
+
+/// Content of an available preset's palette tooltip, built only when first opened.
+public sealed partial class PresetFormatTip(
+ Preset preset, PresetAvailability availability, Func planner, ILocalizer loc) : ObservableObject
+{
+ private static readonly HashSet DefaultTargets = BuiltInData.CreatePresets()
+ .Where(p => !p.IsGroup && p.Kind == PresetKind.Convert
+ && p.TargetFormat is not BuiltInData.SameAsSource and not FormatRegistry.Folder)
+ .Select(p => p.TargetFormat).ToHashSet();
+
+ [ObservableProperty] private string? _title;
+ [ObservableProperty] private string? _makesBefore;
+ [ObservableProperty] private string? _makesName;
+ [ObservableProperty] private string? _makesAfter;
+ [ObservableProperty] private string? _worksWith;
+ [ObservableProperty] private string? _anyFile;
+ [ObservableProperty] private string? _reason;
+ [ObservableProperty] private IReadOnlyList _summaryRows = [];
+ [ObservableProperty] private IReadOnlyList _fullRows = [];
+ [ObservableProperty] private string? _footer;
+
+ private bool _isExpanded;
+ public bool CanExpand => Footer is not null;
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ if (SetProperty(ref _isExpanded, value && CanExpand))
+ {
+ OnPropertyChanged(nameof(Rows));
+ OnPropertyChanged(nameof(ShowFooter));
+ }
+ }
+ }
+
+ public IReadOnlyList Rows => IsExpanded ? FullRows : SummaryRows;
+ public bool ShowFooter => Footer is not null && !IsExpanded;
+ internal int BuildCount { get; private set; }
+
+ partial void OnSummaryRowsChanged(IReadOnlyList value) => OnPropertyChanged(nameof(Rows));
+ partial void OnFullRowsChanged(IReadOnlyList value) => OnPropertyChanged(nameof(Rows));
+ partial void OnFooterChanged(string? value)
+ {
+ OnPropertyChanged(nameof(CanExpand));
+ OnPropertyChanged(nameof(ShowFooter));
+ }
+
+ public static PresetFormatTip Build(Preset preset, PresetAvailability availability, RoutePlanner planner, ILocalizer loc)
+ {
+ var tip = new PresetFormatTip(preset, availability, () => planner, loc);
+ tip.EnsureBuilt();
+ return tip;
+ }
+
+ public void EnsureBuilt()
+ {
+ if (BuildCount != 0)
+ return;
+ BuildCount++;
+ if (preset.IsGroup)
+ return;
+
+ if (!preset.PacksIntoOneArchive)
+ {
+ var usable = availability.UsableFormats(preset, FormatRegistry.Known);
+ if (usable.Count == 0)
+ {
+ Reason = availability.Check(preset, FormatRegistry.Known);
+ return;
+ }
+
+ WorksWith = loc.Format("toolbar.chip_tip.works_with", usable.Count);
+ var routes = planner();
+ var categories = usable.GroupBy(f => f.Category)
+ .Select(group => new
+ {
+ Category = group.Key,
+ Formats = group.OrderByDescending(f => DefaultTargets.Contains(f.Id)).ToList(),
+ MinSteps = group.Min(f => preset.IsTool ? 1
+ : routes.Plan(f.Id, preset.TargetFormat == BuiltInData.SameAsSource ? f.Id : preset.TargetFormat)
+ is { Steps.Count: > 0 } route ? route.Steps.Count : 1),
+ })
+ .OrderBy(g => g.MinSteps == 1 ? 0 : 1)
+ .ThenByDescending(g => g.Formats.Count)
+ .ThenBy(g => g.Category)
+ .ToList();
+
+ string Names(IReadOnlyList formats, int count)
+ {
+ var names = LocalizationService.LeftToRight(string.Join(loc["toolbar.chip_tip.name_separator"],
+ formats.Take(count).Select(f => FormatLabels.Name(loc, f))));
+ return count < formats.Count
+ ? loc.Format("toolbar.chip_tip.names_more", names, formats.Count - count)
+ : names;
+ }
+
+ PresetFormatTipRow Row(FormatCategory category, IReadOnlyList formats, int count) =>
+ new(loc.Format("toolbar.chip_tip.kind", FormatLabels.Category(loc, category), formats.Count),
+ Names(formats, count));
+
+ FullRows = categories.Select(g => Row(g.Category, g.Formats, g.Formats.Count)).ToList();
+ var shown = categories.Where(g => g.MinSteps < 3).Take(6).ToList();
+ SummaryRows = shown.Select(g => Row(g.Category, g.Formats, Math.Min(3, g.Formats.Count))).ToList();
+ var hidden = usable.Count - shown.Sum(g => g.Formats.Count);
+ Footer = hidden > 0 ? loc.Format("toolbar.chip_tip.more_shift", hidden)
+ : shown.Any(g => g.Formats.Count > 3) ? loc["toolbar.chip_tip.shift_all"] : null;
+ }
+ else
+ AnyFile = loc["toolbar.chip_tip.any_file"];
+
+ Title = loc.DisplayName(preset);
+ if (preset.TargetFormat == BuiltInData.SameAsSource)
+ {
+ MakesBefore = loc["toolbar.chip_tip.makes_same"];
+ return;
+ }
+
+ var output = FormatRegistry.Get(preset.TargetFormat);
+ var template = loc[preset.TargetFormat == FormatRegistry.Folder
+ ? "toolbar.chip_tip.makes_name" : "toolbar.chip_tip.makes"];
+ var position = template.IndexOf("{0}", StringComparison.Ordinal);
+ MakesBefore = template[..position];
+ MakesName = LocalizationService.LeftToRight(FormatLabels.Name(loc, output));
+ MakesAfter = template[(position + 3)..].Replace("{1}", FormatLabels.Category(loc, output.Category),
+ StringComparison.Ordinal);
+ }
+}
diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml
index 3b23f15d5..611899134 100644
--- a/src/Filee.App/Views/Pages/ToolbarPage.axaml
+++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml
@@ -9,6 +9,7 @@
@@ -21,6 +22,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -421,7 +478,9 @@
-
+
diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
index a11b9c895..abd33e72d 100644
--- a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
+++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs
@@ -6,6 +6,10 @@
// A floating "ghost" chip follows the pointer, and the donut opens a gap where the drop would land, with a
// translucent slice of the dragged preset in it (DonutMenu.SetDropPreview). The Front / Back switch shows the other
// face of the profile, and the donut turns in to it as the open donut does on Tab.
+//
+// Chip tooltips: an available chip's tip lists the formats it takes (PresetFormatTip), as a card. Holding either Shift
+// shows the whole list while it is open; Tab or the arrow keys open a chip's tip as hover does, and pressing or
+// double-clicking a chip closes it.
using System.ComponentModel;
using Avalonia;
@@ -14,6 +18,7 @@
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using Filee.App.Controls;
+using Filee.App.ViewModels;
using Filee.App.ViewModels.Pages;
namespace Filee.App.Views.Pages;
@@ -33,6 +38,15 @@ private sealed record DragState(Source Source, string? PresetId, int SliceIndex,
private DragState? _drag;
private ToolbarPageViewModel? _subscribed;
+ // Each Shift key on its own: letting go of one while the other is held keeps the list whole.
+ private bool _leftShift;
+ private bool _rightShift;
+ private TopLevel? _topLevel;
+ // The format tips open now, by chip: hover and keyboard focus can each have one open.
+ private readonly Dictionary _openTips = [];
+
+ private bool ShiftDown => _leftShift || _rightShift;
+
public ToolbarPage()
{
InitializeComponent();
@@ -80,6 +94,124 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
private ToolbarPageViewModel? Vm => DataContext as ToolbarPageViewModel;
+ // Shift is watched on the whole window: the pointer can be on a chip while the keyboard focus is elsewhere.
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _topLevel = TopLevel.GetTopLevel(this);
+ if (_topLevel is null)
+ return;
+ _topLevel.AddHandler(KeyDownEvent, OnWindowShiftKey, RoutingStrategies.Tunnel, handledEventsToo: true);
+ _topLevel.AddHandler(KeyUpEvent, OnWindowShiftKey, RoutingStrategies.Tunnel, handledEventsToo: true);
+ if (_topLevel is WindowBase window)
+ {
+ window.Activated += OnWindowActivationChanged;
+ window.Deactivated += OnWindowActivationChanged;
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ if (_topLevel is not null)
+ {
+ _topLevel.RemoveHandler(KeyDownEvent, OnWindowShiftKey);
+ _topLevel.RemoveHandler(KeyUpEvent, OnWindowShiftKey);
+ if (_topLevel is WindowBase window)
+ {
+ window.Activated -= OnWindowActivationChanged;
+ window.Deactivated -= OnWindowActivationChanged;
+ }
+ }
+ _topLevel = null;
+ _leftShift = _rightShift = false;
+ }
+
+ private void OnWindowShiftKey(object? sender, KeyEventArgs e)
+ {
+ var down = e.RoutedEvent == KeyDownEvent;
+ switch (e.Key)
+ {
+ case Key.LeftShift:
+ _leftShift = down;
+ break;
+ case Key.RightShift:
+ _rightShift = down;
+ break;
+ default:
+ return;
+ }
+ ExpandOpenTips();
+ }
+
+ ///
+ /// A Shift let go in another window never reaches this one, so the keys count as up once the window loses (or,
+ /// where the platform only reports that, regains) the keyboard.
+ ///
+ private void OnWindowActivationChanged(object? sender, EventArgs e)
+ {
+ _leftShift = _rightShift = false;
+ ExpandOpenTips();
+ }
+
+ private void ExpandOpenTips()
+ {
+ foreach (var tip in _openTips.Values)
+ tip.IsExpanded = ShiftDown;
+ }
+
+ /// Built when first shown; opens whole while Shift is held.
+ private void OnChipTipOpening(object? sender, CancelRoutedEventArgs e)
+ {
+ if (sender is not Control chip || ToolTip.GetTip(chip) is not PresetFormatTip tip)
+ return;
+ tip.EnsureBuilt();
+ tip.IsExpanded = ShiftDown;
+ _openTips[chip] = tip;
+ }
+
+ ///
+ /// A closed tip no longer follows Shift. Watched on IsOpen: Avalonia 12.1 raises ToolTipClosing while a tip opens and
+ /// not when it closes.
+ ///
+ private void OnChipPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == ToolTip.IsOpenProperty && e.NewValue is false && sender is Control chip)
+ _openTips.Remove(chip);
+ }
+
+ /// The card look goes on the tooltip that shows a format tip (and comes off if it shows something else).
+ private void OnFormatTipAttached(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ if (sender is Control { DataContext: PresetFormatTip } content && content.FindAncestorOfType() is { } toolTip)
+ {
+ toolTip.Classes.Set("formats", true);
+ toolTip.PointerEntered -= OnFormatTipPointerEntered;
+ toolTip.PointerEntered += OnFormatTipPointerEntered;
+ content.Tag = toolTip;
+ }
+ }
+
+ ///
+ /// The pointer on a format tip closes it. Avalonia keeps a tip open while the pointer is over it, and this one opens
+ /// just below the chip, over the chips of the next rows.
+ ///
+ private void OnFormatTipPointerEntered(object? sender, PointerEventArgs e)
+ {
+ if (sender is ToolTip { Content: PresetFormatTip tip } && _openTips.FirstOrDefault(p => p.Value == tip).Key is { } chip)
+ ToolTip.SetIsOpen(chip, false);
+ }
+
+ private void OnFormatTipDetached(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ if (sender is Control { Tag: ToolTip toolTip } content && toolTip.Content is not PresetFormatTip)
+ {
+ toolTip.Classes.Set("formats", false);
+ toolTip.PointerEntered -= OnFormatTipPointerEntered;
+ content.Tag = null;
+ }
+ }
+
/// Down / Up in the wrapped tab row: the tab on the next / previous row nearest below / above this one.
private void OnTabsKeyDown(object? sender, KeyEventArgs e)
{
diff --git a/tests/Filee.App.Tests/PaletteChipTipModelTests.cs b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs
new file mode 100644
index 000000000..1f8d16cdf
--- /dev/null
+++ b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs
@@ -0,0 +1,159 @@
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.ViewModels.Pages;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+
+namespace Filee.App.Tests;
+
+[Collection("MainWindow")]
+public class PaletteChipTipModelTests
+{
+ private static PresetFormatTip Tip(ToolbarPageViewModel vm, string id) =>
+ Assert.IsType(vm.Palette.Single(c => c.PresetId == id).Tip);
+
+ [AvaloniaFact]
+ public void T1_usable_formats_match_single_format_checks_in_registry_order()
+ {
+ var extra = new PresetAvailabilityTests.FakeConverter("test-inputs",
+ ("csv", "xlsx"), ("json", "xlsx"), ("png", "png"), ("jpg", "jpg"));
+ using var f = new PresetAvailabilityTests.Fixture(extra);
+ foreach (var id in new[] { "to-xlsx", "half-size", "tool-crop", "tool-pdf-crop", "test-pack", "test-mp4" })
+ {
+ var preset = f.Preset(id);
+ var expected = FormatRegistry.Known.Where(format =>
+ f.Availability.Check(preset, [format]) is null).ToList();
+ Assert.Equal(expected, f.Availability.UsableFormats(preset, FormatRegistry.Known));
+ }
+ }
+
+ [AvaloniaFact]
+ public void T8_catalog_refresh_replaces_the_tip_and_recomputes_available_formats()
+ {
+ using var f = new PresetAvailabilityTests.Fixture();
+ using var vm = f.PageWithPalette();
+ var before = Tip(vm, "test-jpg");
+ before.EnsureBuilt();
+ Assert.NotEmpty(before.FullRows);
+
+ f.Built.Available = false;
+ f.Catalog.Refresh();
+ Dispatcher.UIThread.RunJobs();
+ var after = Tip(vm, "test-jpg");
+ Assert.NotSame(before, after);
+ Assert.Equal(0, after.BuildCount);
+ after.EnsureBuilt();
+ var expected = PresetFormatTip.Build(f.Preset("test-jpg"), f.Availability, f.Catalog.CreatePlanner(), f.Loc);
+ Assert.Equal(expected.WorksWith, after.WorksWith);
+ Assert.Equal(expected.FullRows, after.FullRows);
+ Assert.Empty(after.FullRows);
+ Assert.DoesNotContain(FormatRegistry.Known, format =>
+ f.Availability.Check(f.Preset("test-jpg"), [format]) is null);
+ Assert.NotEqual(before.WorksWith, after.WorksWith);
+ }
+
+ [AvaloniaFact]
+ public void T8_library_and_preset_edits_replace_only_the_affected_tips()
+ {
+ var extra = new PresetAvailabilityTests.FakeConverter("test-inputs",
+ ("png", "xlsx"), ("json", "xlsx"));
+ using var f = new PresetAvailabilityTests.Fixture(extra);
+ using var vm = f.PageWithPalette();
+ var original = Tip(vm, "test-jpg");
+ var other = Tip(vm, "tool-crop");
+ original.EnsureBuilt();
+ var edited = f.Preset("test-jpg");
+
+ edited.TargetFormat = "xlsx";
+ f.Store.SaveLibrary();
+ Dispatcher.UIThread.RunJobs();
+ var external = Tip(vm, edited.Id);
+ Assert.NotSame(original, external);
+ Assert.NotSame(other, Tip(vm, "tool-crop"));
+ external.EnsureBuilt();
+ Assert.Equal("XLSX", external.MakesName);
+ Assert.Equal(PresetFormatTip.Build(edited, f.Availability, f.Catalog.CreatePlanner(), f.Loc).FullRows,
+ external.FullRows);
+
+ var retained = Tip(vm, "tool-crop");
+ edited.TargetFormat = "jpg";
+ vm.PresetEdited(edited); // Shared by chip and donut-slice editor completion.
+ Dispatcher.UIThread.RunJobs();
+ var changed = Tip(vm, edited.Id);
+ Assert.NotSame(external, changed);
+ Assert.Same(retained, Tip(vm, "tool-crop"));
+ changed.EnsureBuilt();
+ Assert.Equal("JPG", changed.MakesName);
+ Assert.Equal(PresetFormatTip.Build(edited, f.Availability, f.Catalog.CreatePlanner(), f.Loc).FullRows,
+ changed.FullRows);
+ }
+
+ [AvaloniaFact]
+ public void T8b_palette_rebuilds_are_lazy_and_profile_only_edits_keep_the_tip()
+ {
+ using var f = new PresetAvailabilityTests.Fixture();
+ using var vm = f.PageWithPalette("png");
+ Dispatcher.UIThread.RunJobs();
+ var tip = Tip(vm, "test-jpg");
+ Assert.Equal(0, tip.BuildCount);
+
+ vm.SelectedTab = vm.Tabs.Single(t => t.Id == PaletteTab.Image);
+ vm.SelectedTab = vm.Tabs.Single(t => t.Id == PaletteTab.All);
+ Assert.Same(tip, Tip(vm, "test-jpg"));
+ Assert.Equal(0, tip.BuildCount);
+
+ tip.EnsureBuilt();
+ Assert.Equal(1, tip.BuildCount);
+ tip.IsExpanded = true;
+ tip.IsExpanded = false;
+ tip.EnsureBuilt();
+ Assert.Equal(1, tip.BuildCount);
+
+ vm.ProfileName = "Renamed";
+ vm.ExtensionTags!.AddCommand.Execute("jpg");
+ Dispatcher.UIThread.RunJobs();
+ Assert.Same(tip, Tip(vm, "test-jpg"));
+ Assert.Equal(1, tip.BuildCount);
+ }
+
+ [AvaloniaFact]
+ public void T8c_default_target_name_order_ignores_library_edits()
+ {
+ var defaults = BuiltInData.CreatePresets().Where(p => p.Kind == PresetKind.Convert
+ && !p.IsGroup && p.TargetFormat is not BuiltInData.SameAsSource and not FormatRegistry.Folder)
+ .Select(p => p.TargetFormat).ToHashSet();
+ var spreadsheet = FormatRegistry.Known.Where(format => format.Category == FormatCategory.Spreadsheet).ToList();
+ var other = spreadsheet.First(format => !defaults.Contains(format.Id));
+ var sources = spreadsheet.Where(format => format.Id == "csv" || format.Id == other.Id
+ || format.Id == "ods" || format.Id == "xlsx").DistinctBy(format => format.Id).ToList();
+ var extra = new PresetAvailabilityTests.FakeConverter("test-spreadsheet",
+ sources.Select(source => new ConversionEdge(source.Id, "xlsx")).ToList());
+ using var f = new PresetAvailabilityTests.Fixture(extra);
+ using var vm = f.PageWithPalette();
+ var original = Tip(vm, "to-xlsx");
+ original.EnsureBuilt();
+
+ f.Store.Presets.Remove(f.Preset("to-csv"));
+ f.Store.Presets.Add(new Preset { Id = "test-new-spreadsheet", TargetFormat = other.Id });
+ f.Store.SaveLibrary();
+ Dispatcher.UIThread.RunJobs();
+
+ var changed = Tip(vm, "to-xlsx");
+ Assert.NotSame(original, changed);
+ changed.EnsureBuilt();
+ var accepted = FormatRegistry.Known.Where(format =>
+ f.Availability.Check(f.Preset("to-xlsx"), [format]) is null).ToList();
+ var expected = accepted.Where(format => format.Category == FormatCategory.Spreadsheet)
+ .OrderByDescending(format => defaults.Contains(format.Id)).ToList();
+ Assert.Contains(expected, format => format.Id == "csv");
+ Assert.Contains(expected, format => format.Id == other.Id);
+ Assert.Equal(LocalizationService.LeftToRight(string.Join(f.Loc["toolbar.chip_tip.name_separator"],
+ expected.Select(format => FormatLabels.Name(f.Loc, format)))),
+ changed.FullRows.Single(row => row.Kind == f.Loc.Format("toolbar.chip_tip.kind",
+ FormatLabels.Category(f.Loc, FormatCategory.Spreadsheet), expected.Count)).Names);
+ Assert.Equal(original.FullRows, changed.FullRows);
+ }
+}
diff --git a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs
new file mode 100644
index 000000000..254adbe5d
--- /dev/null
+++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs
@@ -0,0 +1,759 @@
+// The format tooltips of the palette chips on the real ToolbarPage (#548): what an open tip shows, Shift showing the
+// whole list, keyboard focus opening a tip and a click closing it. The fixture's fake converters (and one chosen per
+// test) decide which formats a preset takes, so the counts don't depend on the engines installed; every expected row
+// is worked out here from Check, RoutePlanner.Plan, the built-in library and FormatRegistry.Known.
+
+using System.Reflection;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.ViewModels.Pages;
+using Filee.App.Views.Pages;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Profiles;
+using FakeConverter = Filee.App.Tests.PresetAvailabilityTests.FakeConverter;
+using Fixture = Filee.App.Tests.PresetAvailabilityTests.Fixture;
+
+namespace Filee.App.Tests;
+
+[Collection("MainWindow")]
+public class PaletteChipTipViewTests
+{
+ // The ToolTip control Avalonia shows for a control's tip (internal to Avalonia).
+ private static readonly AttachedProperty ToolTipControl = (AttachedProperty)typeof(ToolTip)
+ .GetField("ToolTipProperty", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!;
+
+ private static readonly HashSet DefaultTargets = BuiltInData.CreatePresets()
+ .Where(p => !p.IsGroup && p.Kind == PresetKind.Convert
+ && p.TargetFormat is not BuiltInData.SameAsSource and not FormatRegistry.Folder)
+ .Select(p => p.TargetFormat).ToHashSet();
+
+ ///
+ /// Presets whose formats the "test-tips" converter sets: MP3 from audio in one step and from SVG only in three (a
+ /// kind the summary leaves out), XLSX from four spreadsheets (a row with names left out), DOCX from two texts (nothing
+ /// left out). No target is a source of another, so no route reaches further than these edges say.
+ ///
+ private static FakeConverter TipsConverter() => new("test-tips",
+ ("wav", "mp3"), ("flac", "mp3"), ("svg", "test-step-a"), ("test-step-a", "test-step-b"), ("test-step-b", "mp3"),
+ ("csv", "xlsx"), ("ods", "xlsx"), ("xlsm", "xlsx"), ("xltx", "xlsx"),
+ ("md", "docx"), ("txt", "docx"));
+
+ private static void AddTipPresets(Fixture f) => f.Store.Presets.AddRange(
+ [
+ new() { Id = "test-tip-mp3", TargetFormat = "mp3" },
+ new() { Id = "test-tip-xlsx", TargetFormat = "xlsx" },
+ new() { Id = "test-tip-docx", TargetFormat = "docx" },
+ ]);
+
+ /// A ToolbarPage for a fixture view model in a window, as the main window shows it.
+ private sealed class View : IDisposable
+ {
+ public View(ToolbarPageViewModel vm)
+ {
+ Page = new ToolbarPage { DataContext = vm };
+ Window = new Window { Width = 1080, Height = 820, Content = new ScrollViewer { Content = Page } };
+ Window.Show();
+ RenderTests.Pump();
+ // The keyboard is in this window, as when the user hovers its chips: a key goes to the focused element.
+ Assert.True(Page.FindControl("PaletteSearchBox")!.Focus());
+ RenderTests.Pump();
+ }
+
+ public ToolbarPage Page { get; }
+ public Window Window { get; }
+
+ public List Chips => Page.FindControl("PaletteBox")!.GetVisualDescendants().OfType()
+ .Where(b => b.Classes.Contains("chip") && b.DataContext is PaletteChip).ToList();
+
+ public Border Chip(string id) => Chips.Single(b => ((PaletteChip)b.DataContext!).PresetId == id);
+
+ public static ToolTip Open(Border chip)
+ {
+ ToolTip.SetIsOpen(chip, true);
+ RenderTests.Pump();
+ Assert.True(ToolTip.GetIsOpen(chip));
+ return Assert.IsType(chip.GetValue(ToolTipControl));
+ }
+
+ public static void Close(Control chip)
+ {
+ ToolTip.SetIsOpen(chip, false);
+ RenderTests.Pump();
+ }
+
+ public void Key(Key key, bool down, Window? window = null)
+ {
+ var physical = key == Avalonia.Input.Key.RightShift ? PhysicalKey.ShiftRight : PhysicalKey.ShiftLeft;
+ if (down)
+ (window ?? Window).KeyPress(key, RawInputModifiers.Shift, physical, null);
+ else
+ (window ?? Window).KeyRelease(key, RawInputModifiers.None, physical, null);
+ RenderTests.Pump();
+ }
+
+ public Point Center(Control control) =>
+ control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), Window)!.Value;
+
+ public void Dispose() => Window.Close();
+ }
+
+ /// The visible text of an open tip, line by line as the template draws it.
+ private sealed record Shown(string? Reason, string? Title, string? Makes, string? AnyFile, string? WorksWith,
+ List<(string Kind, string Names)> Rows, string? Footer, List Lines);
+
+ private static string TextOf(TextBlock block) => block.Inlines is { Count: > 0 } inlines
+ ? string.Concat(inlines.OfType().Select(r => r.Text))
+ : block.Text ?? "";
+
+ private static Shown Read(ToolTip tip)
+ {
+ var blocks = tip.GetVisualDescendants().OfType().Where(b => b.IsEffectivelyVisible).ToList();
+ string? Line(string name) => blocks.SingleOrDefault(b => b.Name == name) is { } b ? TextOf(b) : null;
+ var rows = blocks.Where(b => b.Name == "TipKind")
+ .Select(kind => (TextOf(kind), TextOf(((Grid)kind.Parent!).Children.OfType().Single(b => b.Name == "TipNames"))))
+ .ToList();
+ return new(Line("TipReason"), Line("TipTitle"), Line("TipMakes"), Line("TipAnyFile"), Line("TipWorksWith"), rows,
+ Line("TipFooter"), blocks.Select(TextOf).ToList());
+ }
+
+ private sealed record Kind(FormatCategory Category, List Formats, int Steps);
+
+ /// R1-R4 and R6: the kinds a preset takes, in order, each with its formats in order.
+ private static List ExpectedKinds(Fixture f, Preset preset)
+ {
+ var usable = FormatRegistry.Known.Where(format => f.Availability.Check(preset, [format]) is null).ToList();
+ var planner = f.Catalog.CreatePlanner();
+ int Steps(FileFormat format)
+ {
+ if (preset.IsTool)
+ return 1;
+ var target = preset.TargetFormat == BuiltInData.SameAsSource ? format.Id : preset.TargetFormat;
+ return planner.Plan(format.Id, target) is { Steps.Count: > 0 } route ? route.Steps.Count : 1;
+ }
+ return usable.GroupBy(format => format.Category)
+ .Select(g => new Kind(g.Key,
+ [.. g.Where(x => DefaultTargets.Contains(x.Id)), .. g.Where(x => !DefaultTargets.Contains(x.Id))],
+ g.Min(Steps)))
+ .OrderBy(k => k.Steps == 1 ? 0 : 1).ThenByDescending(k => k.Formats.Count).ThenBy(k => (int)k.Category)
+ .ToList();
+ }
+
+ private static (string Kind, string Names) Row(Fixture f, Kind kind, int shown)
+ {
+ var names = LocalizationService.LeftToRight(string.Join(f.Loc["toolbar.chip_tip.name_separator"],
+ kind.Formats.Take(shown).Select(format => FormatLabels.Name(f.Loc, format))));
+ return (f.Loc.Format("toolbar.chip_tip.kind", FormatLabels.Category(f.Loc, kind.Category), kind.Formats.Count),
+ shown < kind.Formats.Count ? f.Loc.Format("toolbar.chip_tip.names_more", names, kind.Formats.Count - shown) : names);
+ }
+
+ private static List SummaryKinds(List kinds) => kinds.Where(k => k.Steps < 3).Take(6).ToList();
+
+ private static List<(string, string)> SummaryRows(Fixture f, List kinds) =>
+ SummaryKinds(kinds).Select(k => Row(f, k, Math.Min(3, k.Formats.Count))).ToList();
+
+ private static List<(string, string)> FullRows(Fixture f, List kinds) =>
+ kinds.Select(k => Row(f, k, k.Formats.Count)).ToList();
+
+ /// R5 and R8: the formats no summary row holds, and the last line.
+ private static (int Hidden, string? Footer) ExpectedFooter(Fixture f, List kinds)
+ {
+ var shown = SummaryKinds(kinds);
+ var hidden = kinds.Sum(k => k.Formats.Count) - shown.Sum(k => k.Formats.Count);
+ return (hidden, hidden > 0 ? f.Loc.Format("toolbar.chip_tip.more_shift", hidden)
+ : shown.Any(k => k.Formats.Count > 3) ? f.Loc["toolbar.chip_tip.shift_all"] : null);
+ }
+
+ /// R7: the second line.
+ private static string ExpectedMakes(Fixture f, Preset preset)
+ {
+ if (preset.TargetFormat == BuiltInData.SameAsSource)
+ return f.Loc["toolbar.chip_tip.makes_same"];
+ var output = FormatRegistry.Get(preset.TargetFormat);
+ var name = LocalizationService.LeftToRight(FormatLabels.Name(f.Loc, output));
+ return preset.TargetFormat == FormatRegistry.Folder
+ ? f.Loc["toolbar.chip_tip.makes_name"].Replace("{0}", name, StringComparison.Ordinal)
+ : f.Loc["toolbar.chip_tip.makes"].Replace("{0}", name, StringComparison.Ordinal)
+ .Replace("{1}", FormatLabels.Category(f.Loc, output.Category), StringComparison.Ordinal);
+ }
+
+ /// A whole summary tip and, with Shift held, the whole list, against what the rules give.
+ private static void AssertTip(Fixture f, View view, string id)
+ {
+ var preset = f.Preset(id);
+ var kinds = ExpectedKinds(f, preset);
+ var count = FormatRegistry.Known.Count(format => f.Availability.Check(preset, [format]) is null);
+ var (hidden, footer) = ExpectedFooter(f, kinds);
+ var chip = view.Chip(id);
+ var shown = Read(View.Open(chip));
+
+ Assert.Equal(f.Loc.DisplayName(preset), shown.Title);
+ Assert.Equal(ExpectedMakes(f, preset), shown.Makes);
+ Assert.Equal(f.Loc.Format("toolbar.chip_tip.works_with", count), shown.WorksWith);
+ Assert.Equal(SummaryRows(f, kinds), shown.Rows);
+ Assert.Equal(count, SummaryKinds(kinds).Sum(k => k.Formats.Count) + hidden);
+ Assert.Equal(footer, shown.Footer);
+ Assert.Null(shown.Reason);
+ Assert.Null(shown.AnyFile);
+
+ view.Key(Key.LeftShift, down: true);
+ var whole = Read(chip.GetValue(ToolTipControl)!);
+ Assert.Equal(FullRows(f, kinds), whole.Rows);
+ Assert.Null(whole.Footer);
+ Assert.Equal(shown.Title, whole.Title);
+ Assert.Equal(shown.Makes, whole.Makes);
+ Assert.Equal(shown.WorksWith, whole.WorksWith);
+ view.Key(Key.LeftShift, down: false);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T2_an_xlsx_chip_tip_shows_what_it_makes_and_the_kinds_it_takes()
+ {
+ var extra = new FakeConverter("test-sheets",
+ ("csv", "xlsx"), ("ods", "xlsx"), ("xlsm", "xlsx"), ("xltx", "xlsx"), ("json", "xlsx"));
+ using var f = new Fixture(extra);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var preset = f.Preset("to-xlsx");
+
+ AssertTip(f, view, preset.Id);
+
+ var shown = Read(View.Open(view.Chip(preset.Id)));
+ Assert.Contains("XLSX", shown.Makes);
+ var spreadsheet = shown.Rows.Single(r => r.Kind.StartsWith(FormatLabels.Category(f.Loc, FormatCategory.Spreadsheet), StringComparison.Ordinal));
+ Assert.Contains("CSV", spreadsheet.Names);
+ // Every name listed is a format the preset takes.
+ var usable = FormatRegistry.Known.Where(format => f.Availability.Check(preset, [format]) is null)
+ .Select(format => FormatLabels.Name(f.Loc, format)).ToHashSet();
+ var listed = shown.Rows.SelectMany(r => r.Names.Split(f.Loc["toolbar.chip_tip.name_separator"]))
+ .Select(name => name.Split(" +")[0]).ToList();
+ Assert.NotEmpty(listed);
+ Assert.All(listed, name => Assert.Contains(name, usable));
+ View.Close(view.Chip(preset.Id));
+ }
+
+ [AvaloniaFact]
+ public void T2_in_arabic_the_names_and_the_output_format_read_left_to_right()
+ {
+ var extra = new FakeConverter("test-sheets", ("csv", "xlsx"), ("ods", "xlsx"), ("xlsm", "xlsx"), ("xltx", "xlsx"));
+ using var f = new Fixture(extra);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ try
+ {
+ TestServices.EnsureInitialized("ar");
+ var preset = f.Preset("to-xlsx");
+ var kinds = ExpectedKinds(f, preset);
+ var shown = Read(View.Open(view.Chip(preset.Id)));
+
+ var output = "\u200E" + FormatLabels.Name(f.Loc, FormatRegistry.Get("xlsx")) + "\u200E";
+ Assert.Contains(output, shown.Makes);
+ Assert.Equal(ExpectedMakes(f, preset), shown.Makes);
+ Assert.NotEmpty(shown.Rows);
+ for (var i = 0; i < shown.Rows.Count; i++)
+ {
+ var names = string.Join(f.Loc["toolbar.chip_tip.name_separator"],
+ kinds[i].Formats.Take(3).Select(format => FormatLabels.Name(f.Loc, format)));
+ Assert.Contains("\u200E" + names + "\u200E", shown.Rows[i].Names);
+ }
+ Assert.Equal(SummaryRows(f, kinds), shown.Rows);
+ View.Close(view.Chip(preset.Id));
+ }
+ finally
+ {
+ TestServices.EnsureInitialized("en");
+ }
+ }
+
+ [AvaloniaFact]
+ public void T3_a_kind_reached_only_in_three_steps_is_counted_in_the_summary_and_listed_with_shift()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var preset = f.Preset("test-tip-mp3");
+ Assert.Equal(3, f.Catalog.CreatePlanner().Plan("svg", "mp3")!.Steps.Count);
+ Assert.Single(f.Catalog.CreatePlanner().Plan("wav", "mp3")!.Steps);
+ var vector = f.Loc.Format("toolbar.chip_tip.kind", FormatLabels.Category(f.Loc, FormatCategory.Vector), 1);
+ var kinds = ExpectedKinds(f, preset);
+ var (hidden, footer) = ExpectedFooter(f, kinds);
+ Assert.Equal(1, hidden);
+
+ var chip = view.Chip(preset.Id);
+ var shown = Read(View.Open(chip));
+ Assert.DoesNotContain(shown.Rows, r => r.Kind == vector);
+ Assert.Contains(shown.Rows, r => r.Kind.StartsWith(FormatLabels.Category(f.Loc, FormatCategory.Audio), StringComparison.Ordinal));
+ Assert.Equal(f.Loc.Format("toolbar.chip_tip.more_shift", 1), shown.Footer);
+ Assert.Equal(footer, shown.Footer);
+
+ view.Key(Key.LeftShift, down: true);
+ var whole = Read(chip.GetValue(ToolTipControl)!);
+ Assert.Contains(whole.Rows, r => r.Kind == vector);
+ Assert.Equal(FullRows(f, kinds), whole.Rows);
+ view.Key(Key.LeftShift, down: false);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T3_kinds_with_a_one_step_route_come_first_and_the_summary_shows_six()
+ {
+ // Eight kinds reach ODT in one step with one format each; Text reaches it only in two steps, with two formats.
+ var kindsConverter = new FakeConverter("test-kinds",
+ ("wav", "odt"), ("mp4", "odt"), ("png", "odt"), ("epub", "odt"), ("zip", "odt"), ("svg", "odt"),
+ ("ttf", "odt"), ("rtf", "odt"), ("txt", "test-mid-a"), ("md", "test-mid-a"), ("test-mid-a", "odt"));
+ using var f = new Fixture(kindsConverter);
+ f.Store.Presets.Add(new() { Id = "test-tip-odt", TargetFormat = "odt" });
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var kinds = ExpectedKinds(f, f.Preset("test-tip-odt"));
+ Assert.True(kinds.Count(k => k.Steps < 3) > 6);
+ var text = Assert.Single(kinds, k => k.Category == FormatCategory.Text);
+ Assert.Equal(2, text.Steps);
+ Assert.Contains(SummaryKinds(kinds), k => k.Formats.Count < text.Formats.Count);
+
+ AssertTip(f, view, "test-tip-odt");
+
+ var shown = Read(View.Open(view.Chip("test-tip-odt")));
+ Assert.Equal(6, shown.Rows.Count);
+ Assert.DoesNotContain(shown.Rows, r => r.Kind.StartsWith(FormatLabels.Category(f.Loc, FormatCategory.Text), StringComparison.Ordinal));
+ View.Close(view.Chip("test-tip-odt"));
+ }
+
+ [AvaloniaFact]
+ public void T4_same_format_folder_archive_and_empty_chips_have_their_own_lines()
+ {
+ var extra = new FakeConverter("test-folders", ("zip", FormatRegistry.Folder), ("7z", FormatRegistry.Folder));
+ using var f = new Fixture(extra);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+
+ // Same format: "→ Same format", the rest as usual.
+ AssertTip(f, view, "half-size");
+ Assert.Equal(f.Loc["toolbar.chip_tip.makes_same"], Read(View.Open(view.Chip("half-size"))).Makes);
+ View.Close(view.Chip("half-size"));
+
+ // A folder: its name, no kind.
+ AssertTip(f, view, "extract");
+ var folder = Read(View.Open(view.Chip("extract")));
+ Assert.Equal(f.Loc["toolbar.chip_tip.makes_name"].Replace("{0}",
+ FormatLabels.Name(f.Loc, FormatRegistry.Get(FormatRegistry.Folder)), StringComparison.Ordinal), folder.Makes);
+ View.Close(view.Chip("extract"));
+
+ // Packed into one archive: the two head lines and "Any file", nothing else.
+ var pack = f.Preset("test-pack");
+ var packed = Read(View.Open(view.Chip(pack.Id)));
+ Assert.Equal(f.Loc.DisplayName(pack), packed.Title);
+ Assert.Equal(ExpectedMakes(f, pack), packed.Makes);
+ Assert.Equal(f.Loc["toolbar.chip_tip.any_file"], packed.AnyFile);
+ Assert.Null(packed.WorksWith);
+ Assert.Empty(packed.Rows);
+ Assert.Null(packed.Footer);
+ Assert.Equal([packed.Title!, packed.Makes!, packed.AnyFile!], packed.Lines);
+ View.Close(view.Chip(pack.Id));
+
+ // Takes no format at all (an extensionless profile doesn't judge it): the reason, alone.
+ var empty = f.Preset("test-wav");
+ Assert.Equal(PresetUse.Usable, ((PaletteChip)view.Chip(empty.Id).DataContext!).Use);
+ Assert.DoesNotContain(FormatRegistry.Known, format => f.Availability.Check(empty, [format]) is null);
+ var reason = Read(View.Open(view.Chip(empty.Id)));
+ Assert.Equal([f.Availability.Check(empty, FormatRegistry.Known)!], reason.Lines);
+ Assert.Equal(reason.Lines[0], reason.Reason);
+ View.Close(view.Chip(empty.Id));
+ }
+
+ [AvaloniaFact]
+ public void T4b_crop_and_pdf_tools_list_the_formats_they_take()
+ {
+ using var f = new Fixture();
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ AssertTip(f, view, "tool-crop");
+ AssertTip(f, view, f.Store.Presets.First(p => p.Id.StartsWith("tool-pdf-", StringComparison.Ordinal)).Id);
+ }
+
+ [AvaloniaFact]
+ public void T4b_the_clip_tool_lists_the_videos_it_can_cut()
+ {
+ using var f = new Fixture(new FakeConverter("test-clipper", ("mp4", "mp4"), ("mkv", "mkv")));
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var clip = f.Store.Presets.First(p => p.IsClipTool);
+ Assert.Contains(FormatRegistry.Known, format => f.Availability.Check(clip, [format]) is null);
+ AssertTip(f, view, clip.Id);
+ }
+
+ [AvaloniaFact]
+ public void T4b_the_sheet_studio_lists_xlsx_with_libreoffice()
+ {
+ using var f = new Fixture(new FakeConverter("libreoffice", ("xlsx", "pdf")));
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ Assert.Null(f.Availability.Check(f.Preset("tool-sheet-studio"), [FormatRegistry.Get("xlsx")]));
+ AssertTip(f, view, "tool-sheet-studio");
+ }
+
+ [AvaloniaFact]
+ public void T5_shift_shows_the_whole_list_while_held_and_either_shift_keeps_it()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-mp3");
+ var tip = View.Open(chip);
+ var summary = Read(tip);
+ Assert.NotNull(summary.Footer);
+
+ view.Key(Key.LeftShift, down: true);
+ var whole = Read(tip);
+ Assert.True(whole.Rows.Count > summary.Rows.Count);
+ Assert.Null(whole.Footer);
+ view.Key(Key.LeftShift, down: false);
+ Assert.Equal(summary.Lines, Read(tip).Lines);
+
+ // Both Shift keys, one let go: still whole. Both let go: short again.
+ view.Key(Key.LeftShift, down: true);
+ view.Key(Key.RightShift, down: true);
+ view.Key(Key.LeftShift, down: false);
+ Assert.Equal(whole.Lines, Read(tip).Lines);
+ view.Key(Key.RightShift, down: false);
+ Assert.Equal(summary.Lines, Read(tip).Lines);
+ view.Key(Key.RightShift, down: true);
+ view.Key(Key.LeftShift, down: true);
+ view.Key(Key.RightShift, down: false);
+ Assert.Equal(whole.Lines, Read(tip).Lines);
+ view.Key(Key.LeftShift, down: false);
+ Assert.Equal(summary.Lines, Read(tip).Lines);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T5_names_left_out_of_a_row_show_with_shift()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var preset = f.Preset("test-tip-xlsx");
+ var kinds = ExpectedKinds(f, preset);
+ Assert.Equal(0, ExpectedFooter(f, kinds).Hidden);
+ var chip = view.Chip(preset.Id);
+ var tip = View.Open(chip);
+ var summary = Read(tip);
+ Assert.Equal(f.Loc["toolbar.chip_tip.shift_all"], summary.Footer);
+
+ view.Key(Key.RightShift, down: true);
+ var whole = Read(tip);
+ Assert.Equal(summary.Rows.Count, whole.Rows.Count);
+ Assert.Equal(FullRows(f, kinds), whole.Rows);
+ Assert.NotEqual(summary.Rows, whole.Rows);
+ Assert.Null(whole.Footer);
+ view.Key(Key.RightShift, down: false);
+ Assert.Equal(summary.Lines, Read(tip).Lines);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T5_a_tip_with_nothing_left_out_has_no_last_line_and_ignores_shift()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-docx");
+ var tip = View.Open(chip);
+ var before = Read(tip);
+ Assert.Null(before.Footer);
+ Assert.NotEmpty(before.Rows);
+
+ view.Key(Key.LeftShift, down: true);
+ Assert.Equal(before.Lines, Read(tip).Lines);
+ view.Key(Key.LeftShift, down: false);
+ Assert.Equal(before.Lines, Read(tip).Lines);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T5_a_tip_opens_short_unless_shift_is_held_when_it_opens()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-mp3");
+ var summary = Read(View.Open(chip)).Lines;
+ view.Key(Key.LeftShift, down: true);
+ var whole = Read(chip.GetValue(ToolTipControl)!).Lines;
+ Assert.NotEqual(summary, whole);
+
+ // Closed whole, Shift let go, opened again: short.
+ View.Close(chip);
+ view.Key(Key.LeftShift, down: false);
+ Assert.Equal(summary, Read(View.Open(chip)).Lines);
+ View.Close(chip);
+
+ // Opened while Shift is held: whole.
+ view.Key(Key.LeftShift, down: true);
+ Assert.Equal(whole, Read(View.Open(chip)).Lines);
+ view.Key(Key.LeftShift, down: false);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T5_shift_let_go_in_another_window_does_not_stick()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-mp3");
+ var summary = Read(View.Open(chip)).Lines;
+ view.Key(Key.LeftShift, down: true);
+ Assert.NotEqual(summary, Read(chip.GetValue(ToolTipControl)!).Lines);
+
+ var other = new Window { Width = 300, Height = 200 };
+ other.Show();
+ try
+ {
+ other.Activate();
+ RenderTests.Pump();
+ view.Key(Key.LeftShift, down: false, other);
+ // The page's window never heard Shift go up.
+ Assert.True(((PresetFormatTip)ToolTip.GetTip(chip)!).IsExpanded);
+ view.Window.Activate();
+ RenderTests.Pump();
+ View.Close(chip);
+ Assert.Equal(summary, Read(View.Open(chip)).Lines);
+ View.Close(chip);
+ }
+ finally
+ {
+ other.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void T5_a_dimmed_chip_keeps_its_reason_with_shift()
+ {
+ using var f = new Fixture();
+ using var vm = f.PageWithPalette("png", "mp4");
+ using var view = new View(vm);
+ var dimmed = view.Chip("test-mp4");
+ var reason = ((PaletteChip)dimmed.DataContext!).Reason;
+ Assert.NotNull(reason);
+ var tip = View.Open(dimmed);
+ Assert.Equal(reason, tip.Content);
+
+ view.Key(Key.LeftShift, down: true);
+ view.Key(Key.RightShift, down: true);
+ Assert.True(ToolTip.GetIsOpen(dimmed));
+ Assert.Equal(reason, tip.Content);
+ Assert.Equal(reason, ToolTip.GetTip(dimmed));
+ view.Key(Key.LeftShift, down: false);
+ view.Key(Key.RightShift, down: false);
+ Assert.True(ToolTip.GetIsOpen(dimmed));
+ Assert.Equal(reason, tip.Content);
+ View.Close(dimmed);
+ }
+
+ /// Two chips next to each other in the palette, both with a tip.
+ private static (Border First, Border Next) TippedPair(View view)
+ {
+ var chips = view.Chips;
+ for (var i = 0; i + 1 < chips.Count; i++)
+ if (ToolTip.GetTip(chips[i]) is PresetFormatTip && ToolTip.GetTip(chips[i + 1]) is PresetFormatTip)
+ return (chips[i], chips[i + 1]);
+ throw new InvalidOperationException("no two chips with a tip next to each other");
+ }
+
+ [AvaloniaFact]
+ public void T7_pressing_dragging_or_double_clicking_a_chip_closes_its_tip()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var (first, next) = TippedPair(view);
+
+ // Press on a chip whose tip is showing.
+ var at = view.Center(first);
+ view.Window.MouseMove(at);
+ View.Open(first);
+ view.Window.MouseDown(at, MouseButton.Left);
+ Assert.False(ToolTip.GetIsOpen(first));
+ view.Window.MouseUp(at, MouseButton.Left);
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(first));
+
+ // Press and drag past the threshold: closed while dragging and after letting go. The tip opens at the pointer, so
+ // it shows below the chip, not over it.
+ var from = view.Center(next);
+ var away = new Point(from.X - 40, from.Y + 40);
+ view.Window.MouseMove(from);
+ View.Open(next);
+ view.Window.MouseDown(from, MouseButton.Left);
+ view.Window.MouseMove(away);
+ RenderTests.Pump();
+ Assert.True(view.Page.FindControl("Ghost")!.IsVisible);
+ Assert.False(ToolTip.GetIsOpen(next));
+ view.Window.MouseUp(away, MouseButton.Left);
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(next));
+
+ var chip = view.Chip("test-tip-docx");
+ View.Open(chip);
+ chip.RaiseEvent(new TappedEventArgs(InputElement.DoubleTappedEvent, null!));
+ Assert.False(ToolTip.GetIsOpen(chip));
+ }
+
+ [AvaloniaFact]
+ public void T7_a_hovered_tip_closes_when_the_pointer_leaves_the_chip()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ // A chip, and one on a row above it: the tip opens below the pointer, so it doesn't cover the one above.
+ var tipped = view.Chips.Where(c => ToolTip.GetTip(c) is PresetFormatTip).ToList();
+ var above = tipped[0];
+ var below = tipped.First(c => view.Center(c).Y > view.Center(above).Y + above.Bounds.Height);
+ Assert.True(view.Center(below).Y < view.Window.Height);
+
+ // The pointer rests on the lower chip until its tip shows (the show delay is not waited for here).
+ view.Window.MouseMove(view.Center(below));
+ View.Open(below);
+ // On to the chip above: the tooltip service moves the tip there at once, as between any two tips.
+ view.Window.MouseMove(view.Center(above));
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(below));
+ Assert.True(ToolTip.GetIsOpen(above));
+ Assert.Equal(1, ((PresetFormatTip)ToolTip.GetTip(above)!).BuildCount);
+
+ // Off the chips, onto the page title: closed.
+ var title = view.Page.GetVisualDescendants().OfType().First(t => t.Classes.Contains("h1"));
+ view.Window.MouseMove(view.Center(title));
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(above));
+ }
+
+ [AvaloniaFact]
+ public void T7_a_tip_closes_when_the_pointer_moves_onto_it()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-xlsx");
+ var at = chip.TranslatePoint(new Point(chip.Bounds.Width / 2, 5), view.Window)!.Value;
+ view.Window.MouseMove(at);
+ var tip = View.Open(chip);
+ var origin = tip.TranslatePoint(default, view.Window)!.Value;
+
+ // Down from the chip onto the tip, which Avalonia would otherwise keep open over the next rows.
+ view.Window.MouseMove(new Point(origin.X + tip.Bounds.Width / 2, origin.Y + tip.Bounds.Height / 2));
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(chip));
+ }
+
+ [AvaloniaFact]
+ public void T9_a_group_chip_has_no_tip_by_hover_or_focus()
+ {
+ using var f = new Fixture();
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var group = view.Chip("test-group");
+ Assert.Null(ToolTip.GetTip(group));
+ Assert.True(group.Focus(NavigationMethod.Tab));
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(group));
+ view.Window.MouseMove(view.Center(group));
+ RenderTests.Pump();
+ Assert.False(ToolTip.GetIsOpen(group));
+ }
+
+ [AvaloniaFact]
+ public void T10_nothing_in_an_open_tip_takes_focus_or_input()
+ {
+ using var f = new Fixture(TipsConverter());
+ AddTipPresets(f);
+ using var vm = f.PageWithPalette();
+ using var view = new View(vm);
+ var chip = view.Chip("test-tip-mp3");
+ var tip = View.Open(chip);
+
+ void AssertReadOnly()
+ {
+ var inside = tip.GetVisualDescendants().OfType().ToList();
+ Assert.NotEmpty(inside.OfType());
+ Assert.DoesNotContain(inside, e => e.Focusable);
+ Assert.DoesNotContain(inside, e => e is Button or TextBox);
+ }
+
+ AssertReadOnly();
+ view.Key(Key.LeftShift, down: true);
+ Assert.True(((PresetFormatTip)ToolTip.GetTip(chip)!).IsExpanded);
+ AssertReadOnly();
+ view.Key(Key.LeftShift, down: false);
+ View.Close(chip);
+ }
+
+ [AvaloniaFact]
+ public void T11_chips_keep_the_default_tooltip_delays()
+ {
+ using var f = new Fixture();
+ using var vm = f.PageWithPalette("png", "mp4");
+ using var view = new View(vm);
+ Assert.NotEmpty(view.Chips);
+ Assert.All(view.Chips, chip =>
+ {
+ Assert.False(chip.IsSet(ToolTip.ShowDelayProperty));
+ Assert.False(chip.IsSet(ToolTip.BetweenShowDelayProperty));
+ });
+ }
+
+ [AvaloniaFact]
+ public void T12_only_a_format_tip_is_a_card()
+ {
+ using var f = new Fixture();
+ using var vm = f.PageWithPalette("png", "mp4");
+ using var view = new View(vm);
+ Assert.True(view.Page.TryFindResource("SurfaceBrush", view.Page.ActualThemeVariant, out var surface));
+
+ var formats = view.Chip("test-jpg");
+ Assert.IsType(ToolTip.GetTip(formats));
+ var card = View.Open(formats);
+ Assert.Contains("formats", card.Classes);
+ Assert.Same(surface, card.Background);
+ View.Close(formats);
+ card = View.Open(formats);
+ Assert.Contains("formats", card.Classes);
+ Assert.Same(surface, card.Background);
+ View.Close(formats);
+
+ var dimmed = view.Chip("test-mp4");
+ var reason = View.Open(dimmed);
+ Assert.IsType(reason.Content);
+ Assert.DoesNotContain("formats", reason.Classes);
+ Assert.NotSame(surface, reason.Background);
+ View.Close(dimmed);
+
+ var badge = view.Page.GetVisualDescendants().OfType()
+ .First(b => b.Classes.Contains("badge") && ToolTip.GetTip(b) is not null);
+ var count = View.Open(badge);
+ Assert.DoesNotContain("formats", count.Classes);
+ Assert.NotSame(surface, count.Background);
+ View.Close(badge);
+ }
+}
diff --git a/tests/Filee.App.Tests/ToolbarPaletteViewTests.cs b/tests/Filee.App.Tests/ToolbarPaletteViewTests.cs
index 4e60e6591..2066268ef 100644
--- a/tests/Filee.App.Tests/ToolbarPaletteViewTests.cs
+++ b/tests/Filee.App.Tests/ToolbarPaletteViewTests.cs
@@ -440,7 +440,11 @@ public void External_library_save_restores_selected_tab_after_rejudging()
Assert.Equal(expected.Use, chip.Use);
Assert.Equal(expected.Use != PresetUse.Usable, border.Classes.Contains("dimmed"));
Assert.Equal(expected.Use != PresetUse.Usable ? 0.55 : 1, border.Opacity);
- Assert.Equal(expected.Reason, ToolTip.GetTip(border));
+ // A usable chip's tip lists the formats it takes (#548); a dimmed one keeps its reason.
+ if (expected.Use == PresetUse.Usable)
+ Assert.IsType(ToolTip.GetTip(border));
+ else
+ Assert.Equal(expected.Reason, ToolTip.GetTip(border));
}
Assert.Equal(PresetUse.Usable, ((PaletteChip)view.Chip("test-wav").DataContext!).Use);
}