Implement architecture improvements for 1.0 release
Priority 1 - Stability: - Dirty region invalidation in SkiaRenderingEngine - Font fallback chain (FontFallbackManager) for emoji/CJK/international text - Input method polish with Fcitx5 support alongside IBus Priority 2 - Platform Integration: - Portal file picker (PortalFilePickerService) with zenity/kdialog fallback - System theme detection (SystemThemeService) for GNOME/KDE/XFCE/etc - Notification actions support with D-Bus callbacks Priority 3 - Performance: - GPU acceleration (GpuRenderingEngine) with OpenGL, software fallback - Virtualization manager (VirtualizationManager) for list recycling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
310
Services/FontFallbackManager.cs
Normal file
310
Services/FontFallbackManager.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages font fallback for text rendering when the primary font
|
||||
/// doesn't contain glyphs for certain characters (emoji, CJK, etc.).
|
||||
/// </summary>
|
||||
public class FontFallbackManager
|
||||
{
|
||||
private static FontFallbackManager? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the singleton instance of the font fallback manager.
|
||||
/// </summary>
|
||||
public static FontFallbackManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_instance ??= new FontFallbackManager();
|
||||
}
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback font chain ordered by priority
|
||||
private readonly string[] _fallbackFonts = new[]
|
||||
{
|
||||
// Primary sans-serif fonts
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
"Liberation Sans",
|
||||
"FreeSans",
|
||||
|
||||
// Emoji fonts
|
||||
"Noto Color Emoji",
|
||||
"Noto Emoji",
|
||||
"Symbola",
|
||||
"Segoe UI Emoji",
|
||||
|
||||
// CJK fonts (Chinese, Japanese, Korean)
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans CJK TC",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans CJK KR",
|
||||
"WenQuanYi Micro Hei",
|
||||
"WenQuanYi Zen Hei",
|
||||
"Droid Sans Fallback",
|
||||
|
||||
// Arabic and RTL scripts
|
||||
"Noto Sans Arabic",
|
||||
"Noto Naskh Arabic",
|
||||
"DejaVu Sans",
|
||||
|
||||
// Indic scripts
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Tamil",
|
||||
"Noto Sans Bengali",
|
||||
"Noto Sans Telugu",
|
||||
|
||||
// Thai
|
||||
"Noto Sans Thai",
|
||||
"Loma",
|
||||
|
||||
// Hebrew
|
||||
"Noto Sans Hebrew",
|
||||
|
||||
// System fallbacks
|
||||
"Sans",
|
||||
"sans-serif"
|
||||
};
|
||||
|
||||
// Cache for typeface lookups
|
||||
private readonly Dictionary<string, SKTypeface?> _typefaceCache = new();
|
||||
private readonly Dictionary<(int codepoint, string preferredFont), SKTypeface?> _glyphCache = new();
|
||||
|
||||
private FontFallbackManager()
|
||||
{
|
||||
// Pre-cache common fallback fonts
|
||||
foreach (var fontName in _fallbackFonts.Take(10))
|
||||
{
|
||||
GetCachedTypeface(fontName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a typeface that can render the specified codepoint.
|
||||
/// Falls back through the font chain if the preferred font doesn't support it.
|
||||
/// </summary>
|
||||
/// <param name="codepoint">The Unicode codepoint to render.</param>
|
||||
/// <param name="preferred">The preferred typeface to use.</param>
|
||||
/// <returns>A typeface that can render the codepoint, or the preferred typeface as fallback.</returns>
|
||||
public SKTypeface GetTypefaceForCodepoint(int codepoint, SKTypeface preferred)
|
||||
{
|
||||
// Check cache first
|
||||
var cacheKey = (codepoint, preferred.FamilyName);
|
||||
if (_glyphCache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached ?? preferred;
|
||||
}
|
||||
|
||||
// Check if preferred font has the glyph
|
||||
if (TypefaceContainsGlyph(preferred, codepoint))
|
||||
{
|
||||
_glyphCache[cacheKey] = preferred;
|
||||
return preferred;
|
||||
}
|
||||
|
||||
// Search fallback fonts
|
||||
foreach (var fontName in _fallbackFonts)
|
||||
{
|
||||
var fallback = GetCachedTypeface(fontName);
|
||||
if (fallback != null && TypefaceContainsGlyph(fallback, codepoint))
|
||||
{
|
||||
_glyphCache[cacheKey] = fallback;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// No fallback found, return preferred (will show tofu)
|
||||
_glyphCache[cacheKey] = null;
|
||||
return preferred;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a typeface that can render all codepoints in the text.
|
||||
/// For mixed scripts, use ShapeTextWithFallback instead.
|
||||
/// </summary>
|
||||
public SKTypeface GetTypefaceForText(string text, SKTypeface preferred)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return preferred;
|
||||
|
||||
// Check first non-ASCII character
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
if (rune.Value > 127)
|
||||
{
|
||||
return GetTypefaceForCodepoint(rune.Value, preferred);
|
||||
}
|
||||
}
|
||||
|
||||
return preferred;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes text with automatic font fallback for mixed scripts.
|
||||
/// Returns a list of text runs, each with its own typeface.
|
||||
/// </summary>
|
||||
public List<TextRun> ShapeTextWithFallback(string text, SKTypeface preferred)
|
||||
{
|
||||
var runs = new List<TextRun>();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return runs;
|
||||
|
||||
var currentRun = new StringBuilder();
|
||||
SKTypeface? currentTypeface = null;
|
||||
int runStart = 0;
|
||||
|
||||
int charIndex = 0;
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
var typeface = GetTypefaceForCodepoint(rune.Value, preferred);
|
||||
|
||||
if (currentTypeface == null)
|
||||
{
|
||||
currentTypeface = typeface;
|
||||
}
|
||||
else if (typeface.FamilyName != currentTypeface.FamilyName)
|
||||
{
|
||||
// Typeface changed - save current run
|
||||
if (currentRun.Length > 0)
|
||||
{
|
||||
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
|
||||
}
|
||||
currentRun.Clear();
|
||||
currentTypeface = typeface;
|
||||
runStart = charIndex;
|
||||
}
|
||||
|
||||
currentRun.Append(rune.ToString());
|
||||
charIndex += rune.Utf16SequenceLength;
|
||||
}
|
||||
|
||||
// Add final run
|
||||
if (currentRun.Length > 0 && currentTypeface != null)
|
||||
{
|
||||
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a typeface is available on the system.
|
||||
/// </summary>
|
||||
public bool IsFontAvailable(string fontFamily)
|
||||
{
|
||||
var typeface = GetCachedTypeface(fontFamily);
|
||||
return typeface != null && typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of available fallback fonts on this system.
|
||||
/// </summary>
|
||||
public IEnumerable<string> GetAvailableFallbackFonts()
|
||||
{
|
||||
foreach (var fontName in _fallbackFonts)
|
||||
{
|
||||
if (IsFontAvailable(fontName))
|
||||
{
|
||||
yield return fontName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SKTypeface? GetCachedTypeface(string fontFamily)
|
||||
{
|
||||
if (_typefaceCache.TryGetValue(fontFamily, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var typeface = SKTypeface.FromFamilyName(fontFamily);
|
||||
|
||||
// Check if we actually got the requested font or a substitution
|
||||
if (typeface != null && !typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Got a substitution, don't cache it as the requested font
|
||||
typeface = null;
|
||||
}
|
||||
|
||||
_typefaceCache[fontFamily] = typeface;
|
||||
return typeface;
|
||||
}
|
||||
|
||||
private bool TypefaceContainsGlyph(SKTypeface typeface, int codepoint)
|
||||
{
|
||||
// Use SKFont to check glyph coverage
|
||||
using var font = new SKFont(typeface, 12);
|
||||
var glyphs = new ushort[1];
|
||||
var chars = char.ConvertFromUtf32(codepoint);
|
||||
font.GetGlyphs(chars, glyphs);
|
||||
|
||||
// Glyph ID 0 is the "missing glyph" (tofu)
|
||||
return glyphs[0] != 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a run of text with a specific typeface.
|
||||
/// </summary>
|
||||
public class TextRun
|
||||
{
|
||||
/// <summary>
|
||||
/// The text content of this run.
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The typeface to use for this run.
|
||||
/// </summary>
|
||||
public SKTypeface Typeface { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The starting character index in the original string.
|
||||
/// </summary>
|
||||
public int StartIndex { get; }
|
||||
|
||||
public TextRun(string text, SKTypeface typeface, int startIndex)
|
||||
{
|
||||
Text = text;
|
||||
Typeface = typeface;
|
||||
StartIndex = startIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StringBuilder for internal use.
|
||||
/// </summary>
|
||||
file class StringBuilder
|
||||
{
|
||||
private readonly List<char> _chars = new();
|
||||
|
||||
public int Length => _chars.Count;
|
||||
|
||||
public void Append(string s)
|
||||
{
|
||||
_chars.AddRange(s);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_chars.Clear();
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return new string(_chars.ToArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user