From f629a2ba611a8eac47977260e72e3e6553aea9d6 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:04:05 +0900 Subject: [PATCH 1/3] feat(toolbar): show what a palette chip takes in its hover tooltip A usable palette chip now shows the preset name, what it makes, how many formats it accepts and up to six kind rows with up to three names each. Kinds reached only in three steps are counted in the last line, and holding Shift shows every kind and every name. Pack into one archive says it takes any file; a chip with no usable format shows its reason. Dimmed and group chips keep their behaviour. The tip is built when first shown and cached per preset, dropped on catalog or library changes and on edits to that preset. Keyboard focus opens it like hovering. New toolbar.chip_tip.* keys are in all 17 language files. Closes #548 --- src/Filee.App/Assets/i18n/ar.json | 12 +- src/Filee.App/Assets/i18n/de.json | 12 +- src/Filee.App/Assets/i18n/en.json | 12 +- src/Filee.App/Assets/i18n/es.json | 12 +- src/Filee.App/Assets/i18n/fr.json | 12 +- src/Filee.App/Assets/i18n/hi.json | 12 +- src/Filee.App/Assets/i18n/ja.json | 12 +- src/Filee.App/Assets/i18n/ko.json | 12 +- src/Filee.App/Assets/i18n/ne.json | 12 +- src/Filee.App/Assets/i18n/pl.json | 12 +- src/Filee.App/Assets/i18n/pt-BR.json | 12 +- src/Filee.App/Assets/i18n/pt-PT.json | 12 +- src/Filee.App/Assets/i18n/ru.json | 12 +- src/Filee.App/Assets/i18n/th.json | 12 +- src/Filee.App/Assets/i18n/tr.json | 12 +- src/Filee.App/Assets/i18n/zh-CN.json | 12 +- src/Filee.App/Assets/i18n/zh-TW.json | 12 +- src/Filee.App/Services/PresetAvailability.cs | 17 + .../ViewModels/Pages/ToolbarPageViewModel.cs | 25 +- src/Filee.App/ViewModels/PresetFormatTip.cs | 138 +++ src/Filee.App/Views/Pages/ToolbarPage.axaml | 62 +- .../Views/Pages/ToolbarPage.axaml.cs | 133 +++ .../PaletteChipTipModelTests.cs | 159 ++++ .../PaletteChipTipViewTests.cs | 811 ++++++++++++++++++ .../ToolbarPaletteViewTests.cs | 6 +- 25 files changed, 1534 insertions(+), 21 deletions(-) create mode 100644 src/Filee.App/ViewModels/PresetFormatTip.cs create mode 100644 tests/Filee.App.Tests/PaletteChipTipModelTests.cs create mode 100644 tests/Filee.App.Tests/PaletteChipTipViewTests.cs 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..f964e6982 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,8 +478,11 @@ - + diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs index a11b9c895..d6901f12d 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,125 @@ 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); + content.Tag = toolTip; + } + } + + 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); + content.Tag = null; + } + } + + /// Tab or an arrow key on a chip shows its tip as hovering does; leaving the chip hides it. + private void OnChipGotFocus(object? sender, FocusChangedEventArgs e) + { + if (sender is Control chip && e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional + && ToolTip.GetTip(chip) is not null) + ToolTip.SetIsOpen(chip, true); + } + + private void OnChipLostFocus(object? sender, FocusChangedEventArgs e) + { + if (sender is Control chip) + ToolTip.SetIsOpen(chip, false); + } + /// 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..a175b889d --- /dev/null +++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs @@ -0,0 +1,811 @@ +// 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 void Tab() + { + Window.KeyPress(Avalonia.Input.Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + Window.KeyRelease(Avalonia.Input.Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + RenderTests.Pump(); + } + + public Point Center(Control control) => + control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), Window)!.Value; + + public Border Focused => Assert.IsType(Window.FocusManager?.GetFocusedElement()); + + 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 T6_tab_opens_the_focused_chip_tip_and_closes_it_when_focus_leaves() + { + using var f = new Fixture(TipsConverter()); + AddTipPresets(f); + using var vm = f.PageWithPalette(); + using var view = new View(vm); + var (first, next) = TippedPair(view); + + Assert.True(first.Focus(NavigationMethod.Tab)); + RenderTests.Pump(); + Assert.True(ToolTip.GetIsOpen(first)); + view.Tab(); + Assert.Same(next, view.Focused); + Assert.False(ToolTip.GetIsOpen(first)); + Assert.True(ToolTip.GetIsOpen(next)); + + // Shift works on a tip opened with the keyboard as on a hovered one. + var tip = (PresetFormatTip)ToolTip.GetTip(next)!; + view.Key(Key.LeftShift, down: true); + Assert.Equal(tip.CanExpand, tip.IsExpanded); + view.Key(Key.LeftShift, down: false); + Assert.False(tip.IsExpanded); + Assert.True(ToolTip.GetIsOpen(next)); + View.Close(next); + } + + [AvaloniaFact] + public void T6_an_arrow_key_focus_opens_the_tip_and_a_click_focus_does_not() + { + using var f = new Fixture(TipsConverter()); + AddTipPresets(f); + using var vm = f.PageWithPalette(); + using var view = new View(vm); + var (first, next) = TippedPair(view); + + // On 34a854bf the arrow keys don't move between chips (a wrap panel in an items control), so the focus moves the + // way directional navigation would. + Assert.True(first.Focus(NavigationMethod.Tab)); + RenderTests.Pump(); + view.Window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); + view.Window.KeyRelease(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); + RenderTests.Pump(); + Assert.Same(first, view.Focused); + Assert.True(next.Focus(NavigationMethod.Directional)); + RenderTests.Pump(); + Assert.False(ToolTip.GetIsOpen(first)); + Assert.True(ToolTip.GetIsOpen(next)); + Assert.True(first.Focus(NavigationMethod.Directional)); + RenderTests.Pump(); + Assert.False(ToolTip.GetIsOpen(next)); + Assert.True(ToolTip.GetIsOpen(first)); + + // Focus from a click doesn't open a tip. + Assert.True(next.Focus(NavigationMethod.Pointer)); + RenderTests.Pump(); + Assert.False(ToolTip.GetIsOpen(first)); + Assert.False(ToolTip.GetIsOpen(next)); + } + + [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 the keyboard opened. + Assert.True(first.Focus(NavigationMethod.Tab)); + RenderTests.Pump(); + Assert.True(ToolTip.GetIsOpen(first)); + var at = view.Center(first); + view.Window.MouseMove(at); + 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 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); } From fd36f204f4b6a5f45c71fb183b44d8851f67502e Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:17:44 +0900 Subject: [PATCH 2/3] fix(toolbar): open the chip tooltip on hover only Keyboard focus opened the tooltip at the mouse pointer, away from the focused chip, and focus opening was not part of what #548 agreed. The tooltip now opens on hover only. --- src/Filee.App/Views/Pages/ToolbarPage.axaml | 1 - .../Views/Pages/ToolbarPage.axaml.cs | 14 ---- .../PaletteChipTipViewTests.cs | 75 +------------------ 3 files changed, 2 insertions(+), 88 deletions(-) diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml index f964e6982..611899134 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml @@ -482,7 +482,6 @@ diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs index d6901f12d..de2fe6454 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs @@ -199,20 +199,6 @@ private void OnFormatTipDetached(object? sender, VisualTreeAttachmentEventArgs e } } - /// Tab or an arrow key on a chip shows its tip as hovering does; leaving the chip hides it. - private void OnChipGotFocus(object? sender, FocusChangedEventArgs e) - { - if (sender is Control chip && e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional - && ToolTip.GetTip(chip) is not null) - ToolTip.SetIsOpen(chip, true); - } - - private void OnChipLostFocus(object? sender, FocusChangedEventArgs e) - { - if (sender is Control chip) - ToolTip.SetIsOpen(chip, false); - } - /// 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/PaletteChipTipViewTests.cs b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs index a175b889d..5dd47d132 100644 --- a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs @@ -102,18 +102,9 @@ public void Key(Key key, bool down, Window? window = null) RenderTests.Pump(); } - public void Tab() - { - Window.KeyPress(Avalonia.Input.Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); - Window.KeyRelease(Avalonia.Input.Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); - RenderTests.Pump(); - } - public Point Center(Control control) => control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), Window)!.Value; - public Border Focused => Assert.IsType(Window.FocusManager?.GetFocusedElement()); - public void Dispose() => Window.Close(); } @@ -587,66 +578,6 @@ private static (Border First, Border Next) TippedPair(View view) throw new InvalidOperationException("no two chips with a tip next to each other"); } - [AvaloniaFact] - public void T6_tab_opens_the_focused_chip_tip_and_closes_it_when_focus_leaves() - { - using var f = new Fixture(TipsConverter()); - AddTipPresets(f); - using var vm = f.PageWithPalette(); - using var view = new View(vm); - var (first, next) = TippedPair(view); - - Assert.True(first.Focus(NavigationMethod.Tab)); - RenderTests.Pump(); - Assert.True(ToolTip.GetIsOpen(first)); - view.Tab(); - Assert.Same(next, view.Focused); - Assert.False(ToolTip.GetIsOpen(first)); - Assert.True(ToolTip.GetIsOpen(next)); - - // Shift works on a tip opened with the keyboard as on a hovered one. - var tip = (PresetFormatTip)ToolTip.GetTip(next)!; - view.Key(Key.LeftShift, down: true); - Assert.Equal(tip.CanExpand, tip.IsExpanded); - view.Key(Key.LeftShift, down: false); - Assert.False(tip.IsExpanded); - Assert.True(ToolTip.GetIsOpen(next)); - View.Close(next); - } - - [AvaloniaFact] - public void T6_an_arrow_key_focus_opens_the_tip_and_a_click_focus_does_not() - { - using var f = new Fixture(TipsConverter()); - AddTipPresets(f); - using var vm = f.PageWithPalette(); - using var view = new View(vm); - var (first, next) = TippedPair(view); - - // On 34a854bf the arrow keys don't move between chips (a wrap panel in an items control), so the focus moves the - // way directional navigation would. - Assert.True(first.Focus(NavigationMethod.Tab)); - RenderTests.Pump(); - view.Window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); - view.Window.KeyRelease(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); - RenderTests.Pump(); - Assert.Same(first, view.Focused); - Assert.True(next.Focus(NavigationMethod.Directional)); - RenderTests.Pump(); - Assert.False(ToolTip.GetIsOpen(first)); - Assert.True(ToolTip.GetIsOpen(next)); - Assert.True(first.Focus(NavigationMethod.Directional)); - RenderTests.Pump(); - Assert.False(ToolTip.GetIsOpen(next)); - Assert.True(ToolTip.GetIsOpen(first)); - - // Focus from a click doesn't open a tip. - Assert.True(next.Focus(NavigationMethod.Pointer)); - RenderTests.Pump(); - Assert.False(ToolTip.GetIsOpen(first)); - Assert.False(ToolTip.GetIsOpen(next)); - } - [AvaloniaFact] public void T7_pressing_dragging_or_double_clicking_a_chip_closes_its_tip() { @@ -656,12 +587,10 @@ public void T7_pressing_dragging_or_double_clicking_a_chip_closes_its_tip() using var view = new View(vm); var (first, next) = TippedPair(view); - // Press on a chip whose tip the keyboard opened. - Assert.True(first.Focus(NavigationMethod.Tab)); - RenderTests.Pump(); - Assert.True(ToolTip.GetIsOpen(first)); + // 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); From 7a7f22772f7b61f28466d687c41e787a253e8ac4 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:59:59 +0900 Subject: [PATCH 3/3] fix(toolbar): close the chip tooltip when the pointer moves onto it Avalonia keeps a tooltip open while the pointer is over it. The format tooltip opens just below the chip, so moving down to the next row landed on it and it stayed open over the chips there. --- .../Views/Pages/ToolbarPage.axaml.cs | 13 +++++++++++++ .../PaletteChipTipViewTests.cs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs index de2fe6454..abd33e72d 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml.cs @@ -186,15 +186,28 @@ 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; } } diff --git a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs index 5dd47d132..254adbe5d 100644 --- a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs @@ -648,6 +648,25 @@ public void T7_a_hovered_tip_closes_when_the_pointer_leaves_the_chip() 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() {