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