From c58170e751eebce8a422cce2a3d681994047de4e Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 11 Nov 2026 00:06:55 +0900 Subject: [PATCH 1/3] feat(theme): add a switch to use the system's UI font Theme -> "Use the system font" puts the system's own UI fonts ahead of the embedded Noto chain: Segoe UI Variable / Segoe UI with Nirmala UI, Leelawadee UI and the UI language's CJK font on Windows, San Francisco and its script fonts on macOS, fontconfig's default sans-serif on Linux. The embedded chain stays whole behind them, so it still draws any script the system fonts lack. The system fonts are named through a new fonts:System collection that matches a letter only in the family asked for. Avalonia's own system collection hands any letter to the platform fallback, which on Windows answers Yu Gothic UI for Han whatever the family, so a plain chain drew every language's Han in Japanese shapes. With the wrapper, Han keeps the UI language's font (#1121). Annotate's labels follow the same chain, as the text box they are typed in does. The setting is off by default and needs no schema step. Refs #1065 Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 6 + src/Filee.App/Assets/i18n/ar.json | 2 + src/Filee.App/Assets/i18n/bn-BD.json | 2 + src/Filee.App/Assets/i18n/de.json | 2 + src/Filee.App/Assets/i18n/en.json | 2 + src/Filee.App/Assets/i18n/es.json | 2 + src/Filee.App/Assets/i18n/fa.json | 2 + src/Filee.App/Assets/i18n/fr.json | 2 + src/Filee.App/Assets/i18n/hi.json | 2 + src/Filee.App/Assets/i18n/id.json | 2 + src/Filee.App/Assets/i18n/it.json | 2 + src/Filee.App/Assets/i18n/ja.json | 2 + src/Filee.App/Assets/i18n/ko.json | 2 + src/Filee.App/Assets/i18n/ne.json | 2 + src/Filee.App/Assets/i18n/pl.json | 2 + src/Filee.App/Assets/i18n/pt-BR.json | 2 + src/Filee.App/Assets/i18n/pt-PT.json | 2 + src/Filee.App/Assets/i18n/ru.json | 2 + src/Filee.App/Assets/i18n/th.json | 2 + src/Filee.App/Assets/i18n/tr.json | 2 + src/Filee.App/Assets/i18n/vi.json | 2 + src/Filee.App/Assets/i18n/zh-CN.json | 2 + src/Filee.App/Assets/i18n/zh-TW.json | 2 + src/Filee.App/Program.cs | 6 +- src/Filee.App/Services/AnnotationFonts.cs | 32 ++-- src/Filee.App/Services/SoftPalette.cs | 66 ++++++-- src/Filee.App/Services/ThemeService.cs | 6 +- src/Filee.App/SystemUiFontCollection.cs | 63 ++++++++ .../ViewModels/Pages/ThemePageViewModel.cs | 7 +- src/Filee.App/Views/Pages/ThemePage.axaml | 8 + src/Filee.Core/Settings/AppSettings.cs | 7 + tests/Filee.App.Tests/SystemFontTests.cs | 147 ++++++++++++++++++ tests/Filee.App.Tests/TestApp.cs | 6 +- .../SystemFontSettingsTests.cs | 41 +++++ 34 files changed, 404 insertions(+), 35 deletions(-) create mode 100644 src/Filee.App/SystemUiFontCollection.cs create mode 100644 tests/Filee.App.Tests/SystemFontTests.cs create mode 100644 tests/Filee.Core.Tests/SystemFontSettingsTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eae75d9fa..508693299 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -395,6 +395,12 @@ Bump `AppSettings.CurrentSchemaVersion` and add a step to `SettingsMigrations` w stay left to right. Light/dark surface colours are theme dictionaries in `Styles/Soft.axaml`; a user's `theme.json` (`CustomTheme`, see [CUSTOM-THEMES.md](CUSTOM-THEMES.md)) overrides them with theme dictionaries in application resources, which are searched first. +- The UI font is one chain (`SoftPalette.FontFamilies`): the embedded Noto fonts (`FileeFontCollection`, + `fonts:Filee#…`), with the UI language's CJK font first so Han takes that region's shapes. Theme → *Use the system + font* puts the system's UI fonts ahead of them (Segoe UI and Windows' script fonts, San Francisco on macOS, + fontconfig's default on Linux), named `fonts:System#…` (`SystemUiFontCollection`): Avalonia's own system collection + answers any letter with the platform's fallback font, whatever family is asked, so a plain name would draw every + language's Han in Yu Gothic UI on Windows. Annotate's labels follow the same chain (`AnnotationFonts`). - What sits on an accent fill is `OnAccentBrush`: white or dark, whichever has the higher WCAG contrast (`SoftPalette.OnAccent`). `SoftPalette.ApplyAccentStates` also gives Fluent's accent controls this label instead of their fixed white, and their hover and pressed fills, which move away from the label when they would cost it contrast. diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 80782389b..3a8533cba 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "لون مخصّص", "theme.corner_radius": "استدارة الزوايا", "theme.donut_opacity": "شفافية شريط الأدوات الدائري", + "theme.system_font": "استخدام خط النظام", + "theme.system_font_hint": "يعرض النصوص بخط نظامك نفسه، كما في التطبيقات الأخرى. تبقى خطوط Filee المدمجة لرسم أي كتابة لا يدعمها.", "theme.reduce_motion": "تقليل الرسوم المتحركة", "theme.reduce_motion_hint": "موصى به للحواسيب القديمة. يعطّل الانتقالات وحركة ارتداد الشريط الدائري.", "theme.window_size": "حجم النوافذ", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index ce08b2d8e..9561601d7 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -1169,6 +1169,8 @@ "theme.custom_color": "কাস্টম", "theme.corner_radius": "কোমলতা", "theme.donut_opacity": "ডোনাট টুলবারের অস্বচ্ছতা", + "theme.system_font": "সিস্টেমের ফন্ট ব্যবহার করুন", + "theme.system_font_hint": "অন্য অ্যাপের মতো লেখা আপনার সিস্টেমের নিজস্ব ফন্টে দেখায়। যে লিপি সেই ফন্টে নেই, তা ফাইলি-র নিজস্ব ফন্টেই আঁকা হয়।", "theme.reduce_motion": "অ্যানিমেশন কমান", "theme.reduce_motion_hint": "পুরোনো পিসির জন্য প্রস্তাবিত। ট্রানজিশন এবং ডোনাটের বাউন্স বন্ধ করে।", "theme.window_size": "উইন্ডোর আকার", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 8b860e163..b0fa37db9 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "Benutzerdefiniert", "theme.corner_radius": "Abrundung", "theme.donut_opacity": "Deckkraft des Ringmenüs", + "theme.system_font": "Systemschrift verwenden", + "theme.system_font_hint": "Zeigt Text in der Schrift Ihres Systems, wie andere Apps. Schriftsysteme, die ihr fehlen, zeichnen weiterhin die mitgelieferten Schriften von Filee.", "theme.reduce_motion": "Animationen reduzieren", "theme.reduce_motion_hint": "Für ältere PCs empfohlen. Deaktiviert Übergänge und das Federn des Ringmenüs.", "theme.window_size": "Fenstergröße", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 4b5b251b7..a704b0430 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Custom", "theme.corner_radius": "Softness", "theme.donut_opacity": "Donut toolbar opacity", + "theme.system_font": "Use the system font", + "theme.system_font_hint": "Shows text in your system's own font, like other apps. Filee's built-in fonts still draw any script it lacks.", "theme.reduce_motion": "Reduce animations", "theme.reduce_motion_hint": "Recommended for older PCs. Turns off transitions and the donut's bounce.", "theme.window_size": "Window size", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index f16375452..c73a35aec 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "Personalizado", "theme.corner_radius": "Redondeo", "theme.donut_opacity": "Opacidad del menú circular", + "theme.system_font": "Usar la fuente del sistema", + "theme.system_font_hint": "Muestra el texto con la fuente de tu sistema, como las demás aplicaciones. Las fuentes integradas de Filee siguen dibujando las escrituras que le falten.", "theme.reduce_motion": "Reducir las animaciones", "theme.reduce_motion_hint": "Recomendado para PC antiguos. Desactiva las transiciones y el rebote del menú circular.", "theme.window_size": "Tamaño de las ventanas", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 2705f457b..168a3b2a3 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -1169,6 +1169,8 @@ "theme.custom_color": "سفارشی", "theme.corner_radius": "نرمی گوشه‌ها", "theme.donut_opacity": "شفافیت نوار ابزار دونات", + "theme.system_font": "استفاده از قلم سیستم", + "theme.system_font_hint": "متن‌ها را مانند برنامه‌های دیگر با قلم خود سیستم نمایش می‌دهد. خط‌هایی که این قلم ندارد همچنان با قلم‌های داخلی Filee نمایش داده می‌شوند.", "theme.reduce_motion": "کاهش انیمیشن‌ها", "theme.reduce_motion_hint": "برای رایانه‌های قدیمی‌تر توصیه می‌شود. جلوه‌های گذار و پرش دونات را غیرفعال می‌کند.", "theme.window_size": "اندازه پنجره", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 4600dda58..82b11a060 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Personnalisée", "theme.corner_radius": "Arrondi", "theme.donut_opacity": "Opacité du menu circulaire", + "theme.system_font": "Utiliser la police du système", + "theme.system_font_hint": "Affiche le texte dans la police de votre système, comme les autres applications. Les polices intégrées de Filee dessinent toujours les écritures qui lui manquent.", "theme.reduce_motion": "Réduire les animations", "theme.reduce_motion_hint": "Recommandé pour les PC anciens. Désactive les transitions et le rebond du menu circulaire.", "theme.window_size": "Taille des fenêtres", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 1475c8fbf..0ecfc9731 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "कस्टम", "theme.corner_radius": "कोमलता", "theme.donut_opacity": "डोनट टूलबार की अपारदर्शिता", + "theme.system_font": "सिस्टम फ़ॉन्ट इस्तेमाल करें", + "theme.system_font_hint": "दूसरे ऐप की तरह टेक्स्ट को आपके सिस्टम के अपने फ़ॉन्ट में दिखाता है। जो लिपि उस फ़ॉन्ट में नहीं है, उसे Filee के अपने फ़ॉन्ट ही दिखाते हैं।", "theme.reduce_motion": "एनिमेशन कम करें", "theme.reduce_motion_hint": "पुराने PC के लिए अनुशंसित। ट्रांज़िशन और डोनट की उछाल बंद कर देता है।", "theme.window_size": "विंडो का आकार", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 10dbe3f76..77654fad1 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "Kustom", "theme.corner_radius": "Kelengkungan", "theme.donut_opacity": "Transparansi toolbar donut", + "theme.system_font": "Gunakan font sistem", + "theme.system_font_hint": "Menampilkan teks dengan font sistem Anda sendiri, seperti aplikasi lain. Aksara yang tidak dimiliki font itu tetap digambar dengan font bawaan Filee.", "theme.reduce_motion": "Kurangi animasi", "theme.reduce_motion_hint": "Disarankan untuk PC lama. Menonaktifkan transisi dan pantulan menu donut.", "theme.window_size": "Ukuran jendela", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index dd168d33e..ee7d98baa 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -1169,6 +1169,8 @@ "theme.custom_color": "Personalizzato", "theme.corner_radius": "Morbidezza", "theme.donut_opacity": "Opacità della barra donut", +"theme.system_font": "Usa il carattere di sistema", +"theme.system_font_hint": "Mostra il testo con il carattere del sistema, come le altre app. Le scritture che gli mancano restano disegnate con i caratteri integrati di Filee.", "theme.reduce_motion": "Riduci le animazioni", "theme.reduce_motion_hint": "Consigliato per i PC più vecchi. Disattiva le transizioni e il rimbalzo del donut.", "theme.window_size": "Dimensione delle finestre", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 35e5cda18..e81475497 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "カスタム", "theme.corner_radius": "角の丸み", "theme.donut_opacity": "ドーナツメニューの不透明度", + "theme.system_font": "システムのフォントを使う", + "theme.system_font_hint": "ほかのアプリと同じく、システムのフォントで文字を表示します。そのフォントにない文字は、引き続き Filee 内蔵のフォントで表示します。", "theme.reduce_motion": "アニメーションを減らす", "theme.reduce_motion_hint": "古いPCにおすすめです。画面切り替え時のアニメーションと、ドーナツメニューの弾む動きを無効にします。", "theme.window_size": "ウィンドウのサイズ", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 9e17dd741..63045db3d 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "직접 선택", "theme.corner_radius": "말랑함", "theme.donut_opacity": "원형 툴바 불투명도", + "theme.system_font": "시스템 글꼴 사용", + "theme.system_font_hint": "다른 앱처럼 시스템 글꼴로 글자를 보여 줘요. 그 글꼴에 없는 문자는 Filee에 들어 있는 글꼴로 그려요.", "theme.reduce_motion": "애니메이션 줄이기", "theme.reduce_motion_hint": "저사양 PC에 권장해요. 전환 효과와 도넛의 튕김 효과를 꺼요.", "theme.window_size": "창 크기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 4992f8b98..7dc535a34 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "अनुकूलित", "theme.corner_radius": "कुनाको गोलाइ (Softness)", "theme.donut_opacity": "डोनट टुलबार अपारदर्शिता", + "theme.system_font": "सिस्टमको फन्ट प्रयोग गर्नुहोस्", + "theme.system_font_hint": "अन्य एपजस्तै पाठलाई तपाईंको सिस्टमकै फन्टमा देखाउँछ। त्यो फन्टमा नभएका लिपिहरू Filee कै फन्टले देखाउँछन्।", "theme.reduce_motion": "एनिमेसन कम गर्नुहोस्", "theme.reduce_motion_hint": "पुराना कम्प्युटरहरूका लागि सिफारिस गरिन्छ। ट्रान्जिसन र डोनटको बाउन्स बन्द गर्दछ।", "theme.window_size": "विन्डोको आकार", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 119b0b7ea..bcd2e2bd7 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Własny", "theme.corner_radius": "Zaokrąglenie", "theme.donut_opacity": "Krycie paska-pierścienia", + "theme.system_font": "Użyj czcionki systemowej", + "theme.system_font_hint": "Wyświetla tekst czcionką systemu, jak inne aplikacje. Pisma, których jej brakuje, nadal rysują wbudowane czcionki Filee.", "theme.reduce_motion": "Ogranicz animacje", "theme.reduce_motion_hint": "Zalecane na starszych komputerach. Wyłącza przejścia i odbijanie pierścienia.", "theme.window_size": "Rozmiar okien", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index d42450243..2e6be947b 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Personalizada", "theme.corner_radius": "Suavidade", "theme.donut_opacity": "Opacidade do menu circular", + "theme.system_font": "Usar a fonte do sistema", + "theme.system_font_hint": "Mostra o texto na fonte do seu sistema, como os outros apps. As fontes embutidas do Filee continuam desenhando as escritas que faltarem nela.", "theme.reduce_motion": "Reduzir animações", "theme.reduce_motion_hint": "Recomendado para PCs mais antigos. Desliga as transições e o salto do menu.", "theme.window_size": "Tamanho das janelas", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 2fadfdea9..fc75847d3 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Personalizada", "theme.corner_radius": "Arredondamento", "theme.donut_opacity": "Opacidade da barra circular", + "theme.system_font": "Usar o tipo de letra do sistema", + "theme.system_font_hint": "Mostra o texto no tipo de letra do sistema, como as outras aplicações. Os tipos de letra incluídos no Filee continuam a desenhar as escritas que lhe faltem.", "theme.reduce_motion": "Reduzir animações", "theme.reduce_motion_hint": "Recomendado para computadores antigos. Desativa as transições e o efeito de ressalto da barra circular.", "theme.window_size": "Escala da interface", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 41e8b5fbf..0574fd471 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Свой", "theme.corner_radius": "Скругление", "theme.donut_opacity": "Непрозрачность кругового меню", + "theme.system_font": "Использовать системный шрифт", + "theme.system_font_hint": "Показывает текст шрифтом вашей системы, как другие приложения. Письменности, которых в нём нет, по-прежнему рисуют встроенные шрифты Filee.", "theme.reduce_motion": "Меньше анимации", "theme.reduce_motion_hint": "Рекомендуется для старых компьютеров. Отключает переходы и подпрыгивание кругового меню.", "theme.window_size": "Размер окон", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 4eefda857..2c6e609bc 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "กำหนดเอง", "theme.corner_radius": "ความโค้งมน", "theme.donut_opacity": "ความทึบแสงของแถบโดนัท", + "theme.system_font": "ใช้ฟอนต์ของระบบ", + "theme.system_font_hint": "แสดงข้อความด้วยฟอนต์ของระบบเหมือนแอปอื่น ๆ ส่วนตัวอักษรที่ฟอนต์นั้นไม่มี ยังคงใช้ฟอนต์ที่มากับ Filee", "theme.reduce_motion": "ลดการเคลื่อนไหว (แอนิเมชัน)", "theme.reduce_motion_hint": "แนะนำสำหรับคอมพิวเตอร์รุ่นเก่า จะปิดเอฟเฟกต์การเปลี่ยนผ่านและการเด้งของโดนัท", "theme.window_size": "ขนาดหน้าต่าง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index c63d7f7e1..39654430f 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -1170,6 +1170,8 @@ "theme.custom_color": "Özel", "theme.corner_radius": "Yumuşaklık", "theme.donut_opacity": "Halka araç çubuğu opaklığı", + "theme.system_font": "Sistem yazı tipini kullan", + "theme.system_font_hint": "Metni diğer uygulamalar gibi sisteminizin kendi yazı tipiyle gösterir. Bu yazı tipinde olmayan yazı sistemlerini yine Filee'nin yerleşik yazı tipleri çizer.", "theme.reduce_motion": "Animasyonları azalt", "theme.reduce_motion_hint": "Eski bilgisayarlar için önerilir. Geçişleri ve halkanın sıçrama efektini kapatır.", "theme.window_size": "Pencere ölçeği", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 3500f7f42..e51bb72c9 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "Tùy chỉnh", "theme.corner_radius": "Độ bo góc", "theme.donut_opacity": "Độ mờ đục của thanh công cụ vòng tròn", + "theme.system_font": "Dùng phông chữ hệ thống", + "theme.system_font_hint": "Hiển thị chữ bằng phông chữ của hệ thống, giống các ứng dụng khác. Những chữ viết mà phông đó không có vẫn được vẽ bằng phông chữ có sẵn của Filee.", "theme.reduce_motion": "Giảm hiệu ứng động", "theme.reduce_motion_hint": "Khuyên dùng cho máy tính cũ. Tắt hiệu ứng chuyển tiếp và hiệu ứng nảy của vòng tròn.", "theme.window_size": "Kích thước cửa sổ", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index bd63aa656..b7fc13d90 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "自定义", "theme.corner_radius": "圆角", "theme.donut_opacity": "环形工具栏不透明度", + "theme.system_font": "使用系统字体", + "theme.system_font_hint": "像其他应用一样,用系统自己的字体显示文字。系统字体缺少的文字仍由 Filee 自带的字体显示。", "theme.reduce_motion": "减少动画", "theme.reduce_motion_hint": "适合配置较低的电脑。关闭过渡效果和圆环的弹跳效果。", "theme.window_size": "窗口大小", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 9841d7bbd..a8467ce62 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -1186,6 +1186,8 @@ "theme.custom_color": "自訂", "theme.corner_radius": "圓角", "theme.donut_opacity": "環形工具列不透明度", + "theme.system_font": "使用系統字型", + "theme.system_font_hint": "像其他應用程式一樣,以系統本身的字型顯示文字。系統字型缺少的文字仍由 Filee 內建的字型顯示。", "theme.reduce_motion": "減少動畫", "theme.reduce_motion_hint": "適合效能較低的電腦。關閉轉場效果和圓環的彈跳效果。", "theme.window_size": "視窗大小", diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index f7cfc7d10..08061b255 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -296,7 +296,11 @@ public static AppBuilder BuildAvaloniaApp() builder = builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] }); builder = builder - .ConfigureFonts(fonts => fonts.AddFontCollection(new FileeFontCollection())) + .ConfigureFonts(fonts => + { + fonts.AddFontCollection(new FileeFontCollection()); + fonts.AddFontCollection(new SystemUiFontCollection()); + }) #if DEBUG .WithDeveloperTools() #endif diff --git a/src/Filee.App/Services/AnnotationFonts.cs b/src/Filee.App/Services/AnnotationFonts.cs index 5a2e3d6d2..e5fb55035 100644 --- a/src/Filee.App/Services/AnnotationFonts.cs +++ b/src/Filee.App/Services/AnnotationFonts.cs @@ -12,22 +12,23 @@ namespace Filee.App.Services; internal static class AnnotationFonts { private const string EmbeddedPrefix = "fonts:Filee#"; + private const string SystemPrefix = SoftPalette.SystemKey + "#"; private static readonly Lazy> Embedded = new(LoadEmbedded); - private static string? _language; + private static (string Language, bool SystemFont)? _chain; /// /// Gives the painter the chain of 's UI font, in bold: at start and when the language - /// changes, so a conversion run later (a queued one, a retry) draws its labels as the window showed them. The fonts - /// are read the first time a label is drawn. + /// or the system font switch changes, so a conversion run later (a queued one, a retry) draws its labels as the + /// window showed them. The fonts are read the first time a label is drawn. /// - public static void Use(string language) + public static void Use(string language, bool systemFont = false) { - if (_language == language) + if (_chain == (language, systemFont)) return; - _language = language; - AnnotationPainter.UseFonts(() => Chain(language)); - var family = SoftPalette.FontFamilyFor(language); + _chain = (language, systemFont); + AnnotationPainter.UseFonts(() => Chain(language, systemFont)); + var family = SoftPalette.FontFamilyFor(language, systemFont); AnnotationPainter.UseFallback(codePoint => FallbackFor(codePoint, family)); } @@ -55,17 +56,18 @@ private static (SKTypeface Face, bool MadeBold)? FallbackFor(int codePoint, Font } } - private static List Chain(string language) + private static List Chain(string language, bool systemFont) { var chain = new List(); - foreach (var family in SoftPalette.FontFamilies(language).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + foreach (var family in SoftPalette.FontFamilies(language, systemFont).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { // "sans-serif" ends the chain: what no family has, the painter finds as the text box's fallback does. - var face = family.StartsWith(EmbeddedPrefix, StringComparison.Ordinal) - ? Embedded.Value.FirstOrDefault(f => f.FamilyName == family[EmbeddedPrefix.Length..]) - : family == "sans-serif" ? null - : SKFontManager.Default.MatchFamily(family, SKFontStyle.Bold) is { } system - && string.Equals(system.FamilyName, family, StringComparison.OrdinalIgnoreCase) ? system : null; + var name = family.StartsWith(SystemPrefix, StringComparison.Ordinal) ? family[SystemPrefix.Length..] : family; + var face = name.StartsWith(EmbeddedPrefix, StringComparison.Ordinal) + ? Embedded.Value.FirstOrDefault(f => f.FamilyName == name[EmbeddedPrefix.Length..]) + : name == "sans-serif" ? null + : SKFontManager.Default.MatchFamily(name, SKFontStyle.Bold) is { } system + && string.Equals(system.FamilyName, name, StringComparison.OrdinalIgnoreCase) ? system : null; if (face is not null && !chain.Contains(face)) chain.Add(face); } diff --git a/src/Filee.App/Services/SoftPalette.cs b/src/Filee.App/Services/SoftPalette.cs index 423bcf47d..d4dfea8ce 100644 --- a/src/Filee.App/Services/SoftPalette.cs +++ b/src/Filee.App/Services/SoftPalette.cs @@ -24,6 +24,12 @@ internal static class SoftPalette private const string Bengali = "fonts:Filee#Noto Sans Bengali"; private const string Thai = "fonts:Filee#Noto Sans Thai"; + /// + /// The installed fonts as SystemUiFontCollection serves them, a letter matched only in the family asked for. Plain + /// names go to Avalonia's system collection, which answers any letter with the platform's fallback font. + /// + internal const string SystemKey = "fonts:System"; + /// Each CJK region's embedded font and its system fonts (Windows, then macOS). private static readonly (string Embedded, string System) Japanese = ("fonts:Filee#Noto Sans JP", "Yu Gothic UI, Meiryo, Hiragino Sans"); @@ -217,30 +223,64 @@ public static void ApplyCornerRadius(IResourceDictionary res, double radius) /// /// The UI font chain. Han characters look different in Japanese, Korean and Chinese fonts, so the UI language's - /// CJK fonts come first, embedded and system ones alike; other languages keep Korean first. + /// CJK fonts come first, embedded and system ones alike; other languages keep Korean first. With + /// (Theme → Use the system font, #1065) the system's own UI fonts come before the + /// embedded ones, which then only draw what the system's lack. /// - public static string FontFamilies(string language) + public static string FontFamilies(string language, bool systemFont = false) { - (string Embedded, string System)[] cjk = - language.StartsWith("ja", StringComparison.OrdinalIgnoreCase) ? [Japanese, Korean, Chinese] - : language.StartsWith("zh", StringComparison.OrdinalIgnoreCase) ? [Chinese, Korean, Japanese] - : [Korean, Chinese, Japanese]; + var cjk = CjkOrder(language); var traditional = AppLanguages.IsTraditionalChinese(language) ? $"{TraditionalChineseSystem}, " : ""; // Noto Sans comes first: the first face also draws the trimming ellipsis, which Noto Sans Arabic lacks. - return $"{Latin}, {Arabic}, {Devanagari}, {Bengali}, {Thai}, {traditional}{string.Join(", ", cjk.Select(f => f.Embedded))}, Segoe UI, Nirmala UI, Leelawadee UI, " + var embedded = $"{Latin}, {Arabic}, {Devanagari}, {Bengali}, {Thai}, {traditional}{string.Join(", ", cjk.Select(f => f.Embedded))}, Segoe UI, Nirmala UI, Leelawadee UI, " + $"{string.Join(", ", cjk.Select(f => f.System))}, Kohinoor Devanagari, Geeza Pro, Thonburi, sans-serif"; + if (!systemFont) + return embedded; + + // Only system fonts without Han go ahead of the CJK list, so Han still takes the UI language's shapes. + var system = OperatingSystem.IsWindows() ? $"Segoe UI Variable Text, Segoe UI, Nirmala UI, Leelawadee UI, {traditional}{string.Join(", ", cjk.Select(f => f.System))}" + : OperatingSystem.IsMacOS() ? $".AppleSystemUIFont, SF Pro Text, SF Pro, Geeza Pro, Kohinoor Devanagari, Kohinoor Bangla, Thonburi, {traditional}{string.Join(", ", cjk.Select(f => f.System))}" + : LinuxDefaultFont(); + if (string.IsNullOrEmpty(system)) + return embedded; + return string.Join(", ", system.Split(", ").Select(name => $"{SystemKey}#{name}")) + ", " + embedded; } + private static (string Embedded, string System)[] CjkOrder(string language) => + language.StartsWith("ja", StringComparison.OrdinalIgnoreCase) ? [Japanese, Korean, Chinese] + : language.StartsWith("zh", StringComparison.OrdinalIgnoreCase) ? [Chinese, Korean, Japanese] + : [Korean, Chinese, Japanese]; + + /// + /// Linux has no one UI font: fontconfig's default sans-serif, the desktop's usual choice, stands in. Left out when it + /// draws Han (a CJK default would give every UI language its Han shapes) or can't be read. Read once, after the font + /// manager is up. + /// + private static string LinuxDefaultFont() + { + if (_linuxDefaultFont is { } known) + return known; + try + { + var name = FontManager.Current.DefaultFontFamily.Name; + return _linuxDefaultFont = FontManager.Current.TryGetGlyphTypeface(new Typeface(name), out var glyphs) + && glyphs.CharacterToGlyphMap.GetGlyph('一') == 0 ? glyphs.FamilyName : ""; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return ""; + } + } + + private static string? _linuxDefaultFont; + /// /// The font chain from Windows' own UI fonts only (no embedded fonts), with the UI language's CJK font first: what /// the installer uses. Every Windows 10 1809+ has these, with bold faces. /// public static string SystemFontFamilies(string language) { - (string Embedded, string System)[] cjk = - language.StartsWith("ja", StringComparison.OrdinalIgnoreCase) ? [Japanese, Korean, Chinese] - : language.StartsWith("zh", StringComparison.OrdinalIgnoreCase) ? [Chinese, Korean, Japanese] - : [Korean, Chinese, Japanese]; + var cjk = CjkOrder(language); var traditional = AppLanguages.IsTraditionalChinese(language) ? $"{TraditionalChineseSystem}, " : ""; return $"Segoe UI, Nirmala UI, Leelawadee UI, {traditional}{string.Join(", ", cjk.Select(f => f.System))}, sans-serif"; } @@ -275,8 +315,8 @@ public static bool IsRightToLeft(string language) } } - /// The UI font for . - public static FontFamily FontFamilyFor(string language) => FontFamily.Parse(FontFamilies(language)); + /// The UI font for (see ). + public static FontFamily FontFamilyFor(string language, bool systemFont = false) => FontFamily.Parse(FontFamilies(language, systemFont)); /// Lightens (amount > 0) or darkens (amount < 0) a colour. public static Color Shade(Color c, double amount) diff --git a/src/Filee.App/Services/ThemeService.cs b/src/Filee.App/Services/ThemeService.cs index 28b9bae73..0859602b7 100644 --- a/src/Filee.App/Services/ThemeService.cs +++ b/src/Filee.App/Services/ThemeService.cs @@ -95,9 +95,9 @@ public void Apply(ThemeSettings theme, string language, CustomTheme? custom = nu // Radii, fonts, flow direction SoftPalette.ApplyCornerRadius(res, theme.CornerRadius); - res["AppFontFamily"] = SoftPalette.FontFamilyFor(language); + res["AppFontFamily"] = SoftPalette.FontFamilyFor(language, theme.UseSystemFont); // Annotate draws its labels in the same fonts (read when a label is first drawn). - AnnotationFonts.Use(language); + AnnotationFonts.Use(language, theme.UseSystemFont); SoftPalette.ApplyFlowDirection(res, language); if (theme.Style == ThemeStyle.MaterialYou) @@ -166,7 +166,7 @@ public void Apply(ThemeSettings theme, string language, CustomTheme? custom = nu } /// The UI font chain for (see ). - internal static string FontFamilies(string language) => SoftPalette.FontFamilies(language); + internal static string FontFamilies(string language, bool systemFont = false) => SoftPalette.FontFamilies(language, systemFont); private static void ApplyCustomOverrides(IResourceDictionary dictionary, IReadOnlyDictionary? colors) { diff --git a/src/Filee.App/SystemUiFontCollection.cs b/src/Filee.App/SystemUiFontCollection.cs new file mode 100644 index 000000000..aad83ae30 --- /dev/null +++ b/src/Filee.App/SystemUiFontCollection.cs @@ -0,0 +1,63 @@ +// The system's installed fonts under fonts:System, for Theme → Use the system font (#1065). Asked whether a family has +// a letter, Avalonia's own system collection doesn't look in that family: it hands the letter to the platform's +// fallback, which on Windows answers Yu Gothic UI for Han whatever the family or the UI language. A chain that starts +// with a system font then drew every language's Han in Japanese shapes. Here a letter is only matched in the family +// asked for, as FileeFontCollection does for the embedded fonts, so SoftPalette's chain decides. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia.Media; +using Avalonia.Media.Fonts; + +namespace Filee.App; + +internal sealed class SystemUiFontCollection : IFontCollection +{ + /// The key SoftPalette's chain names these fonts by: fonts:System#Segoe UI. + internal const string KeyName = "fonts:System"; + + public Uri Key { get; } = new(KeyName, UriKind.Absolute); + + private static IFontCollection System => FontManager.Current.SystemFonts; + + /// Lists none: every family is the system collection's, listed there. + public int Count => 0; + + public FontFamily this[int index] => throw new ArgumentOutOfRangeException(nameof(index)); + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, + string? familyName, CultureInfo? culture, out Typeface typeface) + { + typeface = default; + if (familyName is null + || !TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face) + || !face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) + return false; + typeface = new Typeface(new FontFamily($"{KeyName}#{familyName}"), fontStyle, fontWeight, fontStretch); + return true; + } + + public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => + System.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) => + System.TryGetFamilyTypefaces(familyName, out familyTypefaces); + + public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) => + System.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); + + public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => + System.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface); + + public IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Dispose() + { + // The system collection belongs to the font manager. + } +} diff --git a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs index f824e85b3..30115b6a2 100644 --- a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs @@ -1,5 +1,5 @@ -// Theme: light/dark, accent colour, softness (corner radius), the donut's opacity, reduce animations, and the window -// size (UiZoom). Changes apply live. +// Theme: light/dark, accent colour, softness (corner radius), the donut's opacity, the system font, reduce animations, +// and the window size (UiZoom). Changes apply live. using Avalonia; using Avalonia.Media; @@ -47,6 +47,7 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices DonutOpacity = Math.Round(Math.Clamp(store.Settings.Donut.Opacity, DonutSettings.MinOpacity, 1) * 100); DonutCaptions = store.Settings.Donut.ShowCaptions; ReduceMotion = theme.ReduceMotion ?? platform.PrefersReducedMotion; + UseSystemFont = theme.UseSystemFont; PreviewItems = [ new DonutItem { Id = "a", Label = "PNG" }, @@ -107,6 +108,7 @@ public sealed record PresetItem(string Key, string Name, Color Color); }; [ObservableProperty] private double _cornerRadius; [ObservableProperty] private bool _reduceMotion; + [ObservableProperty] private bool _useSystemFont; /// /// The donut toolbar's opacity in percent: the one setting Donut toolbar → Appearance → Opacity edits too (each @@ -245,6 +247,7 @@ partial void OnAccentHexChanged(string value) } partial void OnCornerRadiusChanged(double value) => Save(t => t.CornerRadius = Math.Round(value)); partial void OnReduceMotionChanged(bool value) => Save(t => t.ReduceMotion = value); + partial void OnUseSystemFontChanged(bool value) => Save(t => t.UseSystemFont = value); partial void OnDonutOpacityChanged(double value) { diff --git a/src/Filee.App/Views/Pages/ThemePage.axaml b/src/Filee.App/Views/Pages/ThemePage.axaml index 68febb94f..49078d492 100644 --- a/src/Filee.App/Views/Pages/ThemePage.axaml +++ b/src/Filee.App/Views/Pages/ThemePage.axaml @@ -264,6 +264,14 @@ + + + + + + + + diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 635731884..a8e2fcda3 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -54,6 +54,13 @@ public sealed class ThemeSettings /// public bool? ReduceMotion { get; set; } + /// + /// Draws the UI in the system's own font (Segoe UI on Windows, San Francisco on macOS, fontconfig's default on + /// Linux) instead of the embedded Noto fonts, which still draw what it lacks. Off as before it existed; a missing + /// value reads as off, so it needs no schema step. + /// + public bool UseSystemFont { get; set; } + public ThemeSettings Clone() => (ThemeSettings)MemberwiseClone(); } diff --git a/tests/Filee.App.Tests/SystemFontTests.cs b/tests/Filee.App.Tests/SystemFontTests.cs new file mode 100644 index 000000000..0859aa5df --- /dev/null +++ b/tests/Filee.App.Tests/SystemFontTests.cs @@ -0,0 +1,147 @@ +// Theme → Use the system font (#1065): the system's UI fonts go ahead of the embedded Noto ones, which stay behind them +// for the scripts the system's lack, and Han keeps the UI language's shapes (#1121). + +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Filee.App.Services; +using Filee.App.ViewModels.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SystemFontTests +{ + public static TheoryData AllLanguages => new(LocalizationService.Languages.Select(l => l.Code)); + + /// Each CJK region's fonts, embedded and system (Windows and macOS). + private static readonly Dictionary CjkFonts = new() + { + ["ja"] = ["Noto Sans JP", "Yu Gothic UI", "Meiryo", "Hiragino Sans"], + ["ko"] = ["Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo"], + ["zh-CN"] = ["Noto Sans SC", "Microsoft YaHei", "PingFang SC"], + ["zh-TW"] = ["Microsoft JhengHei UI", "Microsoft JhengHei", "PingFang TC", "Noto Sans CJK TC"], + }; + + private static List Chain(string language, bool systemFont) => + ThemeService.FontFamilies(language, systemFont).Split(", ").Select(f => f.Replace("fonts:Filee#", "").Replace("fonts:System#", "")).ToList(); + + /// + /// Asked by name: the system collection lists a family under its name in the system's language ("맑은 고딕"), and + /// only once it has been read. + /// + private static bool Installed(string family) => + FontManager.Current.SystemFonts.TryGetGlyphTypeface(family, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out var face) + && face.FamilyName.StartsWith(family.Replace(" Text", "").Replace(" JhengHei UI", " JhengHei"), StringComparison.OrdinalIgnoreCase); + + [Theory] + [MemberData(nameof(AllLanguages))] + public void The_embedded_chain_stays_whole_behind_the_system_fonts(string language) + { + var embedded = ThemeService.FontFamilies(language); + var system = ThemeService.FontFamilies(language, systemFont: true); + + Assert.EndsWith(embedded, system); + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + Assert.False(system.StartsWith("fonts:Filee#", StringComparison.Ordinal), system); + } + + /// The first font that draws Han is still one of the UI language's (Korean for the others). + [Theory] + [InlineData("ja", "ja")] + [InlineData("ko", "ko")] + [InlineData("zh-CN", "zh-CN")] + [InlineData("zh-TW", "zh-TW")] + [InlineData("en", "ko")] + [InlineData("ar", "ko")] + public void Han_keeps_the_ui_languages_shapes_with_the_system_font(string language, string region) + { + var families = Chain(language, systemFont: true); + var allCjk = CjkFonts.Values.SelectMany(f => f).ToHashSet(); + + Assert.Contains(families.First(allCjk.Contains), CjkFonts[region]); + } + + /// What Windows' own UI draws each script in; skipped where the machine lacks that font. + [AvaloniaTheory] + [InlineData("en", 'A', "Segoe UI Variable Text", "Segoe UI")] + [InlineData("ar", 'م', "Segoe UI", null)] + [InlineData("hi", 'न', "Nirmala UI", null)] + [InlineData("th", 'ก', "Leelawadee UI", null)] + [InlineData("ja", '直', "Yu Gothic UI", null)] // 直 has a different shape in each region's font + [InlineData("ko", '直', "Malgun Gothic", null)] + [InlineData("en", '直', "Malgun Gothic", null)] + [InlineData("zh-CN", '直', "Microsoft YaHei", null)] + [InlineData("zh-TW", '直', "Microsoft JhengHei UI", null)] + public void On_windows_a_letter_takes_the_systems_font(string language, char letter, string expected, string? orElse) + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows' UI fonts."); + TestServices.EnsureInitialized("en"); + var want = Installed(expected) ? expected : orElse; + Assert.SkipUnless(want is not null && Installed(want), $"{expected} isn't installed."); + + foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold }) + { + Assert.True(FontManager.Current.TryMatchCharacter(letter, FontStyle.Normal, weight, FontStretch.Normal, + SoftPalette.FontFamilyFor(language, systemFont: true), null, out var typeface)); + // Windows names a weight's face by its own family ("Segoe UI Semibold"), and a font by the family of its + // file ("Segoe UI Variable", "Microsoft JhengHei"). + var stem = want.Replace(" Text", "").Replace(" JhengHei UI", " JhengHei"); + Assert.StartsWith(stem, typeface.GlyphTypeface.FamilyName, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void The_chain_names_the_system_fonts_by_the_collections_key() => + Assert.Equal(SystemUiFontCollection.KeyName, SoftPalette.SystemKey); + + /// Avalonia's own system collection answers Han in Yu Gothic UI whatever family is asked (#1065). + [AvaloniaFact] + public void A_letter_is_only_matched_in_the_system_family_asked_for() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows' UI fonts."); + TestServices.EnsureInitialized("en"); + Assert.SkipUnless(Installed("Segoe UI") && Installed("Malgun Gothic"), "Segoe UI and Malgun Gothic."); + using var fonts = new SystemUiFontCollection(); + + Assert.True(fonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Segoe UI", null, out _)); + Assert.False(fonts.TryMatchCharacter('直', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Segoe UI", null, out _)); + Assert.True(fonts.TryMatchCharacter('直', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Malgun Gothic", null, out var typeface)); + Assert.Equal("Malgun Gothic", typeface.GlyphTypeface.FamilyName); + Assert.False(fonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Not a font", null, out _)); + } + + [AvaloniaFact] + public void The_switch_is_saved_and_puts_the_system_font_into_the_ui() + { + TestServices.EnsureInitialized("en"); + var directory = Directory.CreateTempSubdirectory("filee-theme-").FullName; + var store = new UserDataStore(directory); + store.Load(); + var vm = new ThemePageViewModel(store, AppHost.Get(), AppHost.Get()); + try + { + Assert.False(vm.UseSystemFont); + vm.UseSystemFont = true; + Assert.True(store.Settings.Theme.UseSystemFont); + var reloaded = new UserDataStore(directory); + reloaded.Load(); + Assert.True(reloaded.Settings.Theme.UseSystemFont); + + AppHost.Get().Apply(store.Settings.Theme, "en"); + Assert.Equal(SoftPalette.FontFamilyFor("en", systemFont: true), Application.Current!.Resources["AppFontFamily"]); + + vm.UseSystemFont = false; + AppHost.Get().Apply(store.Settings.Theme, "en"); + Assert.Equal(SoftPalette.FontFamilyFor("en"), Application.Current!.Resources["AppFontFamily"]); + } + finally + { + vm.Dispose(); + TestServices.EnsureInitialized("en"); + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/tests/Filee.App.Tests/TestApp.cs b/tests/Filee.App.Tests/TestApp.cs index 4d2ad6ddc..95df8a75c 100644 --- a/tests/Filee.App.Tests/TestApp.cs +++ b/tests/Filee.App.Tests/TestApp.cs @@ -18,7 +18,11 @@ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UseSkia() .UseHarfBuzz() - .ConfigureFonts(fonts => fonts.AddFontCollection(new FileeFontCollection())) + .ConfigureFonts(fonts => + { + fonts.AddFontCollection(new FileeFontCollection()); + fonts.AddFontCollection(new SystemUiFontCollection()); + }) .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); } diff --git a/tests/Filee.Core.Tests/SystemFontSettingsTests.cs b/tests/Filee.Core.Tests/SystemFontSettingsTests.cs new file mode 100644 index 000000000..220e7466a --- /dev/null +++ b/tests/Filee.Core.Tests/SystemFontSettingsTests.cs @@ -0,0 +1,41 @@ +// The "Use the system font" switch in settings.json (#1065): off for new settings and for settings from before it (it +// needs no schema step), and kept on once switched on. + +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class SystemFontSettingsTests +{ + private static UserDataStore NewStore(TempDir dir) + { + var store = new UserDataStore(dir.Path); + store.Load(); + return store; + } + + [Fact] + public void New_settings_use_the_embedded_fonts() + { + using var dir = new TempDir(); + Assert.False(NewStore(dir).Settings.Theme.UseSystemFont); + } + + [Fact] + public void Settings_from_before_it_keep_the_embedded_fonts_and_the_switch_is_saved() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), + $$"""{ "schemaVersion": {{AppSettings.CurrentSchemaVersion}}, "theme": { "cornerRadius": 8, "accent": "#1D9E75" } }"""); + var store = NewStore(dir); + Assert.False(store.Settings.Theme.UseSystemFont); + Assert.Equal(8, store.Settings.Theme.CornerRadius); + + store.Settings.Theme.UseSystemFont = true; + store.SaveSettings(); + var reloaded = NewStore(dir); + Assert.True(reloaded.Settings.Theme.UseSystemFont); + Assert.Equal("#1D9E75", reloaded.Settings.Theme.Accent); + Assert.True(reloaded.Settings.Theme.Clone().UseSystemFont); + } +} From aee90b6db60964c5c42e81a0298f5b22a2c1b90c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 11 Nov 2026 02:44:52 +0900 Subject: [PATCH 2/3] test(theme): expect the first system font that has the letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's Windows Server images have a Segoe UI without Arabic, so the system-font chain rightly drew م in the embedded Noto Sans Arabic there and the test, which named Segoe UI, failed. The test now asks each system font in the chain, face by face at the weight drawn, whether it has the letter, and expects the first that does, else the embedded font. Han must still land in a font of the UI language's region. Co-Authored-By: Claude Opus 5.5 --- tests/Filee.App.Tests/SystemFontTests.cs | 61 +++++++++++++++++------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/tests/Filee.App.Tests/SystemFontTests.cs b/tests/Filee.App.Tests/SystemFontTests.cs index 0859aa5df..4ad4f338a 100644 --- a/tests/Filee.App.Tests/SystemFontTests.cs +++ b/tests/Filee.App.Tests/SystemFontTests.cs @@ -4,6 +4,7 @@ using Avalonia; using Avalonia.Headless.XUnit; using Avalonia.Media; +using Avalonia.Media.Fonts; using Filee.App.Services; using Filee.App.ViewModels.Pages; using Filee.Core.Localization; @@ -64,35 +65,61 @@ public void Han_keeps_the_ui_languages_shapes_with_the_system_font(string langua Assert.Contains(families.First(allCjk.Contains), CjkFonts[region]); } - /// What Windows' own UI draws each script in; skipped where the machine lacks that font. + /// + /// A letter takes the first of the chain's system fonts whose face at that weight has it, and the embedded font when + /// none has: never the platform's fallback. Which fonts have which letters differs between machines (on GitHub's + /// Windows Server images Segoe UI has no Arabic), so each face is asked. Han also has to land in the UI language's + /// region, whichever of its fonts is installed. + /// [AvaloniaTheory] - [InlineData("en", 'A', "Segoe UI Variable Text", "Segoe UI")] - [InlineData("ar", 'م', "Segoe UI", null)] - [InlineData("hi", 'न', "Nirmala UI", null)] - [InlineData("th", 'ก', "Leelawadee UI", null)] - [InlineData("ja", '直', "Yu Gothic UI", null)] // 直 has a different shape in each region's font - [InlineData("ko", '直', "Malgun Gothic", null)] - [InlineData("en", '直', "Malgun Gothic", null)] - [InlineData("zh-CN", '直', "Microsoft YaHei", null)] - [InlineData("zh-TW", '直', "Microsoft JhengHei UI", null)] - public void On_windows_a_letter_takes_the_systems_font(string language, char letter, string expected, string? orElse) + [InlineData("en", 'A', "Noto Sans", null)] + [InlineData("ar", 'م', "Noto Sans Arabic", null)] + [InlineData("hi", 'न', "Noto Sans Devanagari", null)] + [InlineData("th", 'ก', "Noto Sans Thai", null)] + [InlineData("ja", '直', "Noto Sans JP", "ja")] // 直 has a different shape in each region's font + [InlineData("ko", '直', "Noto Sans KR", "ko")] + [InlineData("en", '直', "Noto Sans KR", "ko")] + [InlineData("zh-CN", '直', "Noto Sans SC", "zh-CN")] + [InlineData("zh-TW", '直', null, "zh-TW")] // no embedded Traditional font + public void On_windows_a_letter_takes_the_first_system_font_that_has_it(string language, char letter, string? embedded, string? region) { Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows' UI fonts."); TestServices.EnsureInitialized("en"); - var want = Installed(expected) ? expected : orElse; - Assert.SkipUnless(want is not null && Installed(want), $"{expected} isn't installed."); + var systemFamilies = ThemeService.FontFamilies(language, systemFont: true).Split(", ") + .TakeWhile(f => f.StartsWith(SoftPalette.SystemKey + "#", StringComparison.Ordinal)) + .Select(f => f[(SoftPalette.SystemKey.Length + 1)..]) + .ToList(); + Assert.NotEmpty(systemFamilies); foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold }) { + var expected = systemFamilies.Select(f => Face(FontManager.Current.SystemFonts, f, weight, letter)).FirstOrDefault(f => f is not null) + ?? (embedded is null ? null : Face(null, $"fonts:Filee#{embedded}", weight, letter)); + Assert.SkipUnless(expected is not null, $"Neither a system font in the chain nor the embedded one has {letter}."); + Assert.True(FontManager.Current.TryMatchCharacter(letter, FontStyle.Normal, weight, FontStretch.Normal, SoftPalette.FontFamilyFor(language, systemFont: true), null, out var typeface)); - // Windows names a weight's face by its own family ("Segoe UI Semibold"), and a font by the family of its - // file ("Segoe UI Variable", "Microsoft JhengHei"). - var stem = want.Replace(" Text", "").Replace(" JhengHei UI", " JhengHei"); - Assert.StartsWith(stem, typeface.GlyphTypeface.FamilyName, StringComparison.OrdinalIgnoreCase); + var actual = typeface.GlyphTypeface.FamilyName; + Assert.Equal(expected, actual); + if (region is not null) + Assert.Contains(CjkFonts[region], f => actual.StartsWith(f.Replace(" UI", ""), StringComparison.OrdinalIgnoreCase)); } } + /// + /// The family of 's face at when it has the letter: from the + /// system's fonts, or (no collection) an embedded font, which counts only when it was fetched and not stood in for. + /// + private static string? Face(IFontCollection? collection, string family, FontWeight weight, char letter) + { + GlyphTypeface? face; + var found = collection is null + ? FontManager.Current.TryGetGlyphTypeface(new Typeface(family, FontStyle.Normal, weight), out face) + && face.FamilyName == family[(family.IndexOf('#') + 1)..] + : collection.TryGetGlyphTypeface(family, FontStyle.Normal, weight, FontStretch.Normal, out face); + return found && face!.CharacterToGlyphMap.TryGetGlyph(letter, out _) ? face.FamilyName : null; + } + [Fact] public void The_chain_names_the_system_fonts_by_the_collections_key() => Assert.Equal(SystemUiFontCollection.KeyName, SoftPalette.SystemKey); From 3130c2e715d38d0a88140cc268d1fada2cbf87e6 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 11 Nov 2026 04:42:52 +0900 Subject: [PATCH 3/3] fix(theme): keep the default font as a real match in the system chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avalonia's font manager (12.1.4, FontManager.TryMatchCharacter) passes over a match whose family is named like its default font unless that family ends the chain, taking it for a collection's "nothing found" answer. On Windows the default is Segoe UI, so a letter Segoe UI Variable lacks, Arabic above all, skipped Segoe UI for the embedded Noto Sans Arabic. That is what CI's Windows jobs saw: the glyph check found م in Segoe UI, the chain gave Noto. SystemUiFontCollection only answers for the family asked for, so its answer for the default font is a real one: it now returns that family under an alias only it reads back. A test builds a chain with the default font in the middle and checks it is taken. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/SystemUiFontCollection.cs | 22 ++++++++++++++++++---- tests/Filee.App.Tests/SystemFontTests.cs | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Filee.App/SystemUiFontCollection.cs b/src/Filee.App/SystemUiFontCollection.cs index aad83ae30..ef9f68ba2 100644 --- a/src/Filee.App/SystemUiFontCollection.cs +++ b/src/Filee.App/SystemUiFontCollection.cs @@ -3,6 +3,11 @@ // fallback, which on Windows answers Yu Gothic UI for Han whatever the family or the UI language. A chain that starts // with a system font then drew every language's Han in Japanese shapes. Here a letter is only matched in the family // asked for, as FileeFontCollection does for the embedded fonts, so SoftPalette's chain decides. +// +// The font manager also passes over a match whose family is named like its default font (Segoe UI on Windows) unless +// that family ends the chain: it takes it for a collection's "nothing found" answer (Avalonia 12.1.4, +// FontManager.TryMatchCharacter). Here the default font is a real answer, so it comes back under an alias that only this +// collection reads. Without it, Arabic on Windows 11 skipped Segoe UI for the embedded Noto Sans Arabic. using System.Collections; using System.Diagnostics.CodeAnalysis; @@ -19,6 +24,9 @@ internal sealed class SystemUiFontCollection : IFontCollection public Uri Key { get; } = new(KeyName, UriKind.Absolute); + /// Marks the default font's name in a match, so the font manager doesn't pass over it. + private const string Alias = "|asked"; + private static IFontCollection System => FontManager.Current.SystemFonts; /// Lists none: every family is the system collection's, listed there. @@ -34,16 +42,19 @@ public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fon || !TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face) || !face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) return false; - typeface = new Typeface(new FontFamily($"{KeyName}#{familyName}"), fontStyle, fontWeight, fontStretch); + var name = Unaliased(familyName); + if (name == FontManager.Current.DefaultFontFamily.Name) + name += Alias; + typeface = new Typeface(new FontFamily($"{KeyName}#{name}"), fontStyle, fontWeight, fontStretch); return true; } public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => - System.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); + System.TryGetGlyphTypeface(Unaliased(familyName), style, weight, stretch, out glyphTypeface); public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) => - System.TryGetFamilyTypefaces(familyName, out familyTypefaces); + System.TryGetFamilyTypefaces(Unaliased(familyName), out familyTypefaces); public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) => @@ -51,7 +62,10 @@ public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontSty public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => - System.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface); + System.TryGetNearestMatch(Unaliased(familyName), style, weight, stretch, out glyphTypeface); + + private static string Unaliased(string familyName) => + familyName.EndsWith(Alias, StringComparison.Ordinal) ? familyName[..^Alias.Length] : familyName; public IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/tests/Filee.App.Tests/SystemFontTests.cs b/tests/Filee.App.Tests/SystemFontTests.cs index 4ad4f338a..7f22a86e4 100644 --- a/tests/Filee.App.Tests/SystemFontTests.cs +++ b/tests/Filee.App.Tests/SystemFontTests.cs @@ -140,6 +140,27 @@ public void A_letter_is_only_matched_in_the_system_family_asked_for() Assert.False(fonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Not a font", null, out _)); } + /// + /// The font manager passes over a match named like its default font unless that family ends the chain; the + /// collection's answer is a real one, so the default font (Segoe UI on Windows) still draws what it has. + /// + [AvaloniaFact] + public void The_default_font_is_not_passed_over_in_the_middle_of_the_chain() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows' UI fonts."); + TestServices.EnsureInitialized("en"); + var defaultFont = FontManager.Current.DefaultFontFamily.Name; + Assert.SkipUnless(Face(FontManager.Current.SystemFonts, defaultFont, FontWeight.Normal, 'A') is not null + && Face(FontManager.Current.SystemFonts, "Tahoma", FontWeight.Normal, 'A') is not null, "The default font and Tahoma."); + var chain = FontFamily.Parse($"{SoftPalette.SystemKey}#Not a font, {SoftPalette.SystemKey}#{defaultFont}, {SoftPalette.SystemKey}#Tahoma"); + + foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold }) + { + Assert.True(FontManager.Current.TryMatchCharacter('A', FontStyle.Normal, weight, FontStretch.Normal, chain, null, out var typeface)); + Assert.Equal(Face(FontManager.Current.SystemFonts, defaultFont, weight, 'A'), typeface.GlyphTypeface.FamilyName); + } + } + [AvaloniaFact] public void The_switch_is_saved_and_puts_the_system_font_into_the_ui() {