diff --git a/Hosting/LinuxMauiAppBuilderExtensions.cs b/Hosting/LinuxMauiAppBuilderExtensions.cs index a7f234f..5fc43fa 100644 --- a/Hosting/LinuxMauiAppBuilderExtensions.cs +++ b/Hosting/LinuxMauiAppBuilderExtensions.cs @@ -54,6 +54,24 @@ public static class LinuxMauiAppBuilderExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + // Register theming and accessibility services + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + // Register accessibility service + builder.Services.TryAddSingleton(_ => AccessibilityServiceFactory.Instance); + + // Register input method service + builder.Services.TryAddSingleton(_ => InputMethodServiceFactory.Instance); + + // Register font fallback manager + builder.Services.TryAddSingleton(_ => FontFallbackManager.Instance); + + // Register additional Linux-specific services + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + // Register GTK host service builder.Services.TryAddSingleton(_ => GtkHostService.Instance); diff --git a/LinuxApplication.cs b/LinuxApplication.cs index 95f4912..76c677e 100644 --- a/LinuxApplication.cs +++ b/LinuxApplication.cs @@ -265,17 +265,27 @@ public class LinuxApplication : IDisposable currentProperty.SetValue(null, mauiApplication); } - // Handle theme changes + // Handle user-initiated theme changes ((BindableObject)mauiApplication).PropertyChanged += (s, e) => { if (e.PropertyName == "UserAppTheme") { - Console.WriteLine($"[LinuxApplication] Theme changed to: {mauiApplication.UserAppTheme}"); + Console.WriteLine($"[LinuxApplication] User theme changed to: {mauiApplication.UserAppTheme}"); LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme(); linuxApp._renderingEngine?.InvalidateAll(); } }; + // Handle system theme changes (e.g., GNOME/KDE dark mode toggle) + SystemThemeService.Instance.ThemeChanged += (s, e) => + { + Console.WriteLine($"[LinuxApplication] System theme changed to: {e.NewTheme}"); + // Notify MAUI framework that system theme changed + // This will cause AppThemeBinding to re-evaluate + LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme(); + linuxApp._renderingEngine?.InvalidateAll(); + }; + if (mauiApplication.MainPage != null) { var mainPage = mauiApplication.MainPage; diff --git a/Services/AppInfoService.cs b/Services/AppInfoService.cs index 1165793..5508c2d 100644 --- a/Services/AppInfoService.cs +++ b/Services/AppInfoService.cs @@ -39,23 +39,13 @@ public class AppInfoService : IAppInfo { get { - try + // Use SystemThemeService for consistent theme detection across the platform + return SystemThemeService.Instance.CurrentTheme switch { - var environmentVariable = Environment.GetEnvironmentVariable("GTK_THEME"); - if (!string.IsNullOrEmpty(environmentVariable) && environmentVariable.Contains("dark", StringComparison.OrdinalIgnoreCase)) - { - return AppTheme.Dark; - } - if (GetGnomeColorScheme().Contains("dark", StringComparison.OrdinalIgnoreCase)) - { - return AppTheme.Dark; - } - return AppTheme.Light; - } - catch - { - return AppTheme.Light; - } + SystemTheme.Dark => AppTheme.Dark, + SystemTheme.Light => AppTheme.Light, + _ => AppTheme.Unspecified + }; } } @@ -88,31 +78,6 @@ public class AppInfoService : IAppInfo _buildString = _entryAssembly.GetCustomAttribute()?.InformationalVersion ?? _versionString; } - private string GetGnomeColorScheme() - { - try - { - using Process? process = Process.Start(new ProcessStartInfo - { - FileName = "gsettings", - Arguments = "get org.gnome.desktop.interface color-scheme", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }); - if (process != null) - { - string text = process.StandardOutput.ReadToEnd(); - process.WaitForExit(1000); - return text.Trim().Trim('\''); - } - } - catch - { - } - return ""; - } - public void ShowSettingsUI() { try diff --git a/Views/SkiaEditor.cs b/Views/SkiaEditor.cs index 5a36253..6767e65 100644 --- a/Views/SkiaEditor.cs +++ b/Views/SkiaEditor.cs @@ -6,14 +6,16 @@ using System.Collections.Generic; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform.Linux.Rendering; +using Microsoft.Maui.Platform.Linux.Services; using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// Skia-rendered multiline text editor control with full XAML styling support. +/// Implements IInputContext for IME (Input Method Editor) support. /// -public class SkiaEditor : SkiaView +public class SkiaEditor : SkiaView, IInputContext { #region BindableProperties @@ -344,6 +346,30 @@ public class SkiaEditor : SkiaView return string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily; } + /// + /// Determines if text should be rendered right-to-left based on FlowDirection. + /// + private bool IsRightToLeft() + { + return FlowDirection == FlowDirection.RightToLeft; + } + + /// + /// Gets the horizontal alignment accounting for FlowDirection. + /// + private float GetEffectiveTextX(SKRect contentBounds, float textWidth) + { + bool isRtl = IsRightToLeft(); + + return HorizontalTextAlignment switch + { + TextAlignment.Start => isRtl ? contentBounds.Right - textWidth : contentBounds.Left, + TextAlignment.Center => contentBounds.MidX - textWidth / 2, + TextAlignment.End => isRtl ? contentBounds.Left : contentBounds.Right - textWidth, + _ => isRtl ? contentBounds.Right - textWidth : contentBounds.Left + }; + } + #endregion #region Properties @@ -609,6 +635,96 @@ public class SkiaEditor : SkiaView private float _lastClickY; private const double DoubleClickThresholdMs = 400; + // IME (Input Method Editor) support + private string _preEditText = string.Empty; + private int _preEditCursorPosition; + private IInputMethodService? _inputMethodService; + + #region IInputContext Implementation + + /// + /// Gets or sets the text for IME context. + /// + string IInputContext.Text + { + get => Text; + set => Text = value; + } + + /// + /// Gets or sets the cursor position for IME context. + /// + int IInputContext.CursorPosition + { + get => _cursorPosition; + set => CursorPosition = value; + } + + /// + /// Gets the selection start for IME context. + /// + int IInputContext.SelectionStart => _selectionStart; + + /// + /// Gets the selection length for IME context. + /// + int IInputContext.SelectionLength => _selectionLength; + + /// + /// Called when IME commits text. + /// + public void OnTextCommitted(string text) + { + if (IsReadOnly) return; + + // Delete selection if any + if (_selectionLength != 0) + { + DeleteSelection(); + } + + // Clear pre-edit text + _preEditText = string.Empty; + _preEditCursorPosition = 0; + + // Check max length + if (MaxLength > 0 && Text.Length + text.Length > MaxLength) + { + text = text.Substring(0, MaxLength - Text.Length); + } + + // Insert committed text at cursor + var newText = Text.Insert(_cursorPosition, text); + var newPos = _cursorPosition + text.Length; + Text = newText; + _cursorPosition = newPos; + + EnsureCursorVisible(); + Invalidate(); + } + + /// + /// Called when IME pre-edit (composition) text changes. + /// + public void OnPreEditChanged(string preEditText, int cursorPosition) + { + _preEditText = preEditText ?? string.Empty; + _preEditCursorPosition = cursorPosition; + Invalidate(); + } + + /// + /// Called when IME pre-edit ends (cancelled or committed). + /// + public void OnPreEditEnded() + { + _preEditText = string.Empty; + _preEditCursorPosition = 0; + Invalidate(); + } + + #endregion + /// /// Event raised when text changes. /// @@ -622,6 +738,8 @@ public class SkiaEditor : SkiaView public SkiaEditor() { IsFocusable = true; + // Get IME service from factory + _inputMethodService = InputMethodServiceFactory.Instance; } private void OnTextPropertyChanged(string oldText, string newText) @@ -855,15 +973,42 @@ public class SkiaEditor : SkiaView } } - canvas.DrawText(line, x, y, textPaint); + // Determine if pre-edit text should be displayed on this line + var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition); + var displayLine = line; + var hasPreEditOnThisLine = !string.IsNullOrEmpty(_preEditText) && cursorLine == lineIndex; + + if (hasPreEditOnThisLine) + { + // Insert pre-edit text at cursor position within this line + var insertPos = Math.Min(cursorCol, line.Length); + displayLine = line.Insert(insertPos, _preEditText); + } + + // Draw the text with font fallback for emoji/CJK support + DrawTextWithFallback(canvas, displayLine, x, y, textPaint, SKTypeface.Default); + + // Draw underline for pre-edit (composition) text + if (hasPreEditOnThisLine) + { + DrawPreEditUnderline(canvas, textPaint, line, x, y, contentRect); + } // Draw cursor if on this line if (IsFocused && _cursorVisible) { - var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition); if (cursorLine == lineIndex) { - var cursorX = x + MeasureText(line.Substring(0, Math.Min(cursorCol, line.Length)), font); + // Account for pre-edit text when calculating cursor position + var textToCursor = line.Substring(0, Math.Min(cursorCol, line.Length)); + var cursorX = x + MeasureText(textToCursor, font); + + // If there's pre-edit text, cursor goes after it + if (hasPreEditOnThisLine && _preEditText.Length > 0) + { + cursorX += MeasureText(_preEditText, font); + } + using var cursorPaint = new SKPaint { Color = ToSKColor(CursorColor), @@ -1247,12 +1392,24 @@ public class SkiaEditor : SkiaView { base.OnFocusGained(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused); + + // Connect to IME service + _inputMethodService?.SetFocus(this); + + // Update cursor location for IME candidate window positioning + UpdateImeCursorLocation(); } public override void OnFocusLost() { base.OnFocusLost(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal); + + // Disconnect from IME service and reset any composition + _inputMethodService?.SetFocus(null); + _preEditText = string.Empty; + _preEditCursorPosition = 0; + Completed?.Invoke(this, EventArgs.Empty); } @@ -1340,6 +1497,91 @@ public class SkiaEditor : SkiaView #endregion + /// + /// Draws text with font fallback for emoji, CJK, and other scripts. + /// + private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + // Use FontFallbackManager for mixed-script text + var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); + + if (runs.Count <= 1) + { + // Single run or no fallback needed - draw directly + canvas.DrawText(text, x, y, paint); + return; + } + + // Multiple runs with different fonts + float currentX = x; + foreach (var run in runs) + { + using var runFont = new SKFont(run.Typeface, (float)FontSize); + using var runPaint = new SKPaint(runFont) + { + Color = paint.Color, + IsAntialias = true + }; + + canvas.DrawText(run.Text, currentX, y, runPaint); + currentX += runPaint.MeasureText(run.Text); + } + } + + /// + /// Draws underline for IME pre-edit (composition) text. + /// + private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds) + { + // Calculate pre-edit text position + var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); + var preEditStartX = x + paint.MeasureText(textToCursor); + var preEditEndX = preEditStartX + paint.MeasureText(_preEditText); + + // Draw dotted underline to indicate composition + using var underlinePaint = new SKPaint + { + Color = paint.Color, + StrokeWidth = 1, + IsAntialias = true, + PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0) + }; + + var underlineY = y + 2; + canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint); + } + + /// + /// Updates the IME cursor location for candidate window positioning. + /// + private void UpdateImeCursorLocation() + { + if (_inputMethodService == null) return; + + var screenBounds = ScreenBounds; + var (line, col) = GetLineColumn(_cursorPosition); + var fontSize = (float)FontSize; + var lineSpacing = fontSize * (float)LineHeight; + + using var font = new SKFont(SKTypeface.Default, fontSize); + using var paint = new SKPaint(font); + + var lineText = line < _lines.Count ? _lines[line] : ""; + var textToCursor = lineText.Substring(0, Math.Min(col, lineText.Length)); + var cursorX = paint.MeasureText(textToCursor); + + int x = (int)(screenBounds.Left + Padding.Left + cursorX); + int y = (int)(screenBounds.Top + Padding.Top + line * lineSpacing - _scrollOffsetY); + int height = (int)fontSize; + + _inputMethodService.SetCursorLocation(x, y, 2, height); + } + protected override SKSize MeasureOverride(SKSize availableSize) { if (AutoSize) diff --git a/Views/SkiaEntry.cs b/Views/SkiaEntry.cs index a5c9231..fc439ff 100644 --- a/Views/SkiaEntry.cs +++ b/Views/SkiaEntry.cs @@ -13,8 +13,9 @@ namespace Microsoft.Maui.Platform; /// /// Skia-rendered text entry control with full XAML styling and data binding support. +/// Implements IInputContext for IME (Input Method Editor) support. /// -public class SkiaEntry : SkiaView +public class SkiaEntry : SkiaView, IInputContext { #region BindableProperties @@ -662,6 +663,11 @@ public class SkiaEntry : SkiaView private float _lastClickX; private const double DoubleClickThresholdMs = 400; + // IME (Input Method Editor) support + private string _preEditText = string.Empty; + private int _preEditCursorPosition; + private IInputMethodService? _inputMethodService; + /// /// Event raised when text changes. /// @@ -675,8 +681,96 @@ public class SkiaEntry : SkiaView public SkiaEntry() { IsFocusable = true; + // Get IME service from factory + _inputMethodService = InputMethodServiceFactory.Instance; } + #region IInputContext Implementation + + /// + /// Gets or sets the text for IME context. + /// + string IInputContext.Text + { + get => Text; + set => Text = value; + } + + /// + /// Gets or sets the cursor position for IME context. + /// + int IInputContext.CursorPosition + { + get => _cursorPosition; + set => CursorPosition = value; + } + + /// + /// Gets the selection start for IME context. + /// + int IInputContext.SelectionStart => _selectionStart; + + /// + /// Gets the selection length for IME context. + /// + int IInputContext.SelectionLength => _selectionLength; + + /// + /// Called when IME commits text. + /// + public void OnTextCommitted(string text) + { + if (IsReadOnly) return; + + // Delete selection if any + if (_selectionLength != 0) + { + DeleteSelection(); + } + + // Clear pre-edit text + _preEditText = string.Empty; + _preEditCursorPosition = 0; + + // Check max length + if (MaxLength > 0 && Text.Length + text.Length > MaxLength) + { + text = text.Substring(0, MaxLength - Text.Length); + } + + // Insert committed text at cursor + var newText = Text.Insert(_cursorPosition, text); + var newPos = _cursorPosition + text.Length; + Text = newText; + _cursorPosition = newPos; + + ResetCursorBlink(); + Invalidate(); + } + + /// + /// Called when IME pre-edit (composition) text changes. + /// + public void OnPreEditChanged(string preEditText, int cursorPosition) + { + _preEditText = preEditText ?? string.Empty; + _preEditCursorPosition = cursorPosition; + ResetCursorBlink(); + Invalidate(); + } + + /// + /// Called when IME pre-edit ends (cancelled or committed). + /// + public void OnPreEditEnded() + { + _preEditText = string.Empty; + _preEditCursorPosition = 0; + Invalidate(); + } + + #endregion + /// /// Converts a MAUI Color to SkiaSharp SKColor for rendering. /// @@ -714,6 +808,30 @@ public class SkiaEntry : SkiaView return string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily; } + /// + /// Determines if text should be rendered right-to-left based on FlowDirection. + /// + private bool IsRightToLeft() + { + return FlowDirection == FlowDirection.RightToLeft; + } + + /// + /// Gets the horizontal alignment accounting for FlowDirection. + /// + private float GetEffectiveTextX(SKRect contentBounds, float textWidth) + { + bool isRtl = IsRightToLeft(); + + return HorizontalTextAlignment switch + { + TextAlignment.Start => isRtl ? contentBounds.Right - textWidth - _scrollOffset : contentBounds.Left - _scrollOffset, + TextAlignment.Center => contentBounds.MidX - textWidth / 2, + TextAlignment.End => isRtl ? contentBounds.Left - _scrollOffset : contentBounds.Right - textWidth - _scrollOffset, + _ => isRtl ? contentBounds.Right - textWidth - _scrollOffset : contentBounds.Left - _scrollOffset + }; + } + private void OnTextPropertyChanged(string oldText, string newText) { _cursorPosition = Math.Min(_cursorPosition, (newText ?? "").Length); @@ -777,7 +895,12 @@ public class SkiaEntry : SkiaView using var paint = new SKPaint(font) { IsAntialias = true }; var displayText = GetDisplayText(); - var hasText = !string.IsNullOrEmpty(displayText); + // Append pre-edit text at cursor position for IME composition display + var preEditInsertPos = Math.Min(_cursorPosition, displayText.Length); + var displayTextWithPreEdit = string.IsNullOrEmpty(_preEditText) + ? displayText + : displayText.Insert(preEditInsertPos, _preEditText); + var hasText = !string.IsNullOrEmpty(displayTextWithPreEdit); if (hasText) { @@ -815,7 +938,14 @@ public class SkiaEntry : SkiaView _ => contentBounds.MidY - textBounds.MidY // Center }; - canvas.DrawText(displayText, x, y, paint); + // Draw the text with font fallback for emoji/CJK support + DrawTextWithFallback(canvas, displayTextWithPreEdit, x, y, paint, typeface); + + // Draw underline for pre-edit (composition) text + if (!string.IsNullOrEmpty(_preEditText)) + { + DrawPreEditUnderline(canvas, paint, displayText, x, y, contentBounds); + } // Draw cursor if (IsFocused && !IsReadOnly && _cursorVisible) @@ -953,6 +1083,65 @@ public class SkiaEntry : SkiaView canvas.DrawLine(cursorX, bounds.Top + 2, cursorX, bounds.Bottom - 2, cursorPaint); } + /// + /// Draws text with font fallback for emoji, CJK, and other scripts. + /// + private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + // Use FontFallbackManager for mixed-script text + var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); + + if (runs.Count <= 1) + { + // Single run or no fallback needed - draw directly + canvas.DrawText(text, x, y, paint); + return; + } + + // Multiple runs with different fonts + float currentX = x; + foreach (var run in runs) + { + using var runFont = new SKFont(run.Typeface, (float)FontSize); + using var runPaint = new SKPaint(runFont) + { + Color = paint.Color, + IsAntialias = true + }; + + canvas.DrawText(run.Text, currentX, y, runPaint); + currentX += runPaint.MeasureText(run.Text); + } + } + + /// + /// Draws underline for IME pre-edit (composition) text. + /// + private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds) + { + // Calculate pre-edit text position + var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); + var preEditStartX = x + paint.MeasureText(textToCursor); + var preEditEndX = preEditStartX + paint.MeasureText(_preEditText); + + // Draw dotted underline to indicate composition + using var underlinePaint = new SKPaint + { + Color = paint.Color, + StrokeWidth = 1, + IsAntialias = true, + PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0) + }; + + var underlineY = y + 2; + canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint); + } + private void ResetCursorBlink() { _cursorBlinkTime = DateTime.UtcNow; @@ -1445,12 +1634,49 @@ public class SkiaEntry : SkiaView { base.OnFocusGained(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused); + + // Connect to IME service + _inputMethodService?.SetFocus(this); + + // Update cursor location for IME candidate window positioning + UpdateImeCursorLocation(); } public override void OnFocusLost() { base.OnFocusLost(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal); + + // Disconnect from IME service and reset any composition + _inputMethodService?.SetFocus(null); + _preEditText = string.Empty; + _preEditCursorPosition = 0; + } + + /// + /// Updates the IME cursor location for candidate window positioning. + /// + private void UpdateImeCursorLocation() + { + if (_inputMethodService == null) return; + + var screenBounds = ScreenBounds; + var fontStyle = GetFontStyle(); + var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle) + ?? SKTypeface.Default; + + using var font = new SKFont(typeface, (float)FontSize); + using var paint = new SKPaint(font); + + var displayText = GetDisplayText(); + var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); + var cursorX = paint.MeasureText(textToCursor); + + int x = (int)(screenBounds.Left + Padding.Left - _scrollOffset + cursorX); + int y = (int)(screenBounds.Top + Padding.Top); + int height = (int)FontSize; + + _inputMethodService.SetCursorLocation(x, y, 2, height); } protected override SKSize MeasureOverride(SKSize availableSize) diff --git a/Views/SkiaLabel.cs b/Views/SkiaLabel.cs index e36cd92..afe56df 100644 --- a/Views/SkiaLabel.cs +++ b/Views/SkiaLabel.cs @@ -7,6 +7,7 @@ using System.Linq; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform.Linux.Rendering; +using Microsoft.Maui.Platform.Linux.Services; using SkiaSharp; namespace Microsoft.Maui.Platform; @@ -645,6 +646,31 @@ public class SkiaLabel : SkiaView isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); } + /// + /// Determines if text should be rendered right-to-left based on FlowDirection. + /// + private bool IsRightToLeft() + { + return FlowDirection == FlowDirection.RightToLeft; + } + + /// + /// Gets the effective horizontal alignment for the given alignment, + /// accounting for FlowDirection (RTL flips Start/End). + /// + private float GetHorizontalPosition(TextAlignment alignment, float boundsLeft, float boundsRight, float textWidth) + { + bool isRtl = IsRightToLeft(); + + return alignment switch + { + TextAlignment.Start => isRtl ? boundsRight - textWidth : boundsLeft, + TextAlignment.Center => (boundsLeft + boundsRight) / 2 - textWidth / 2, + TextAlignment.End => isRtl ? boundsLeft : boundsRight - textWidth, + _ => isRtl ? boundsRight - textWidth : boundsLeft + }; + } + #endregion #region Drawing @@ -719,14 +745,8 @@ public class SkiaLabel : SkiaView textWidth += (float)(CharacterSpacing * (displayText.Length - 1)); } - // Calculate position based on alignment - float x = HorizontalTextAlignment switch - { - TextAlignment.Start => bounds.Left, - TextAlignment.Center => bounds.MidX - textWidth / 2, - TextAlignment.End => bounds.Right - textWidth, - _ => bounds.Left - }; + // Calculate position based on alignment and FlowDirection + float x = GetHorizontalPosition(HorizontalTextAlignment, bounds.Left, bounds.Right, textWidth); float y = VerticalTextAlignment switch { @@ -804,13 +824,8 @@ public class SkiaLabel : SkiaView textWidth += (float)(CharacterSpacing * (line.Length - 1)); } - float x = HorizontalTextAlignment switch - { - TextAlignment.Start => bounds.Left, - TextAlignment.Center => bounds.MidX - textWidth / 2, - TextAlignment.End => bounds.Right - textWidth, - _ => bounds.Left - }; + // Use FlowDirection-aware positioning + float x = GetHorizontalPosition(HorizontalTextAlignment, bounds.Left, bounds.Right, textWidth); float textY = y - textBounds.Top; DrawTextWithSpacing(canvas, line, x, textY, paint); @@ -823,18 +838,118 @@ public class SkiaLabel : SkiaView private void DrawTextWithSpacing(SKCanvas canvas, string text, float x, float y, SKPaint paint) { - if (CharacterSpacing == 0 || string.IsNullOrEmpty(text) || text.Length <= 1) + if (string.IsNullOrEmpty(text)) return; + + // Get the preferred typeface from the current paint + var fontFamily = string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily; + var preferredTypeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, GetFontStyle()) + ?? SKTypeface.Default; + + if (CharacterSpacing == 0 || text.Length <= 1) { + // No character spacing - use font fallback for the whole string + DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface); + return; + } + + // With character spacing, we need to draw character by character with fallback + float currentX = x; + float fontSize = FontSize > 0 ? (float)FontSize : 14f; + + // Use font fallback to get runs for proper glyph coverage + var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); + + foreach (var run in runs) + { + // Draw each character in the run with spacing + foreach (char c in run.Text) + { + string charStr = c.ToString(); + using var charFont = new SKFont(run.Typeface, fontSize); + using var charPaint = new SKPaint(charFont) + { + Color = paint.Color, + IsAntialias = true + }; + + canvas.DrawText(charStr, currentX, y, charPaint); + currentX += charPaint.MeasureText(charStr) + (float)CharacterSpacing; + } + } + } + + /// + /// Draws text with font fallback for emoji, CJK, and other scripts. + /// + private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + // Use FontFallbackManager for mixed-script text + var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); + + if (runs.Count <= 1) + { + // Single run or no fallback needed - draw directly canvas.DrawText(text, x, y, paint); return; } + // Multiple runs with different fonts + float fontSize = FontSize > 0 ? (float)FontSize : 14f; float currentX = x; - foreach (char c in text) + + foreach (var run in runs) { - string charStr = c.ToString(); - canvas.DrawText(charStr, currentX, y, paint); - currentX += paint.MeasureText(charStr) + (float)CharacterSpacing; + using var runFont = new SKFont(run.Typeface, fontSize); + using var runPaint = new SKPaint(runFont) + { + Color = paint.Color, + IsAntialias = true + }; + + canvas.DrawText(run.Text, currentX, y, runPaint); + currentX += runPaint.MeasureText(run.Text); + } + } + + /// + /// Draws formatted span text with font fallback for emoji, CJK, and other scripts. + /// + private void DrawFormattedSpanWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface, float fontSize) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + // Use FontFallbackManager for mixed-script text + var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); + + if (runs.Count <= 1) + { + // Single run or no fallback needed - draw directly + canvas.DrawText(text, x, y, paint); + return; + } + + // Multiple runs with different fonts + float currentX = x; + + foreach (var run in runs) + { + using var runFont = new SKFont(run.Typeface, fontSize); + using var runPaint = new SKPaint(runFont) + { + Color = paint.Color, + IsAntialias = true + }; + + canvas.DrawText(run.Text, currentX, y, runPaint); + currentX += runPaint.MeasureText(run.Text); } } @@ -926,7 +1041,10 @@ public class SkiaLabel : SkiaView y += lineHeight; } - canvas.DrawText(span.Text, x, y, paint); + // Use font fallback for this span + var preferredTypeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(spanFontFamily, fontStyle) + ?? SKTypeface.Default; + DrawFormattedSpanWithFallback(canvas, span.Text, x, y, paint, preferredTypeface, spanFontSize); // Draw span decorations if (span.TextDecorations != TextDecorations.None) diff --git a/Views/SkiaView.cs b/Views/SkiaView.cs index 2665813..e4a5985 100644 --- a/Views/SkiaView.cs +++ b/Views/SkiaView.cs @@ -6,6 +6,7 @@ using Microsoft.Maui.Controls.Shapes; using Microsoft.Maui.Platform.Linux; using Microsoft.Maui.Platform.Linux.Handlers; using Microsoft.Maui.Platform.Linux.Rendering; +using Microsoft.Maui.Platform.Linux.Services; using Microsoft.Maui.Platform.Linux.Window; using SkiaSharp; @@ -14,8 +15,9 @@ namespace Microsoft.Maui.Platform; /// /// Base class for all Skia-rendered views on Linux. /// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager. +/// Implements IAccessible for screen reader support. /// -public abstract class SkiaView : BindableObject, IDisposable +public abstract class SkiaView : BindableObject, IDisposable, IAccessible { // Popup overlay system for dropdowns, calendars, etc. private static readonly List<(SkiaView Owner, Action Draw)> _popupOverlays = new(); @@ -80,6 +82,243 @@ public abstract class SkiaView : BindableObject, IDisposable return Bounds.Contains(x, y); } + #region High Contrast Support + + private static HighContrastService? _highContrastService; + private static bool _highContrastInitialized; + + /// + /// Gets whether high contrast mode is enabled. + /// + public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false; + + /// + /// Gets the current high contrast colors, or default colors if not in high contrast mode. + /// + public static HighContrastColors GetHighContrastColors() + { + InitializeHighContrastService(); + return _highContrastService?.GetColors() ?? new HighContrastColors + { + Background = SKColors.White, + Foreground = new SKColor(33, 33, 33), + Accent = new SKColor(33, 150, 243), + Border = new SKColor(200, 200, 200), + Error = new SKColor(244, 67, 54), + Success = new SKColor(76, 175, 80), + Warning = new SKColor(255, 152, 0), + Link = new SKColor(33, 150, 243), + LinkVisited = new SKColor(156, 39, 176), + Selection = new SKColor(33, 150, 243), + SelectionText = SKColors.White, + DisabledText = new SKColor(158, 158, 158), + DisabledBackground = new SKColor(238, 238, 238) + }; + } + + private static void InitializeHighContrastService() + { + if (_highContrastInitialized) return; + _highContrastInitialized = true; + + try + { + _highContrastService = new HighContrastService(); + _highContrastService.HighContrastChanged += OnHighContrastChanged; + _highContrastService.Initialize(); + } + catch + { + // Ignore errors - high contrast is optional + } + } + + private static void OnHighContrastChanged(object? sender, HighContrastChangedEventArgs e) + { + // Request a full repaint of the UI + SkiaRenderingEngine.Current?.InvalidateAll(); + } + + #endregion + + #region Accessibility Support (IAccessible) + + private static IAccessibilityService? _accessibilityService; + private static bool _accessibilityInitialized; + private string _accessibleId = Guid.NewGuid().ToString(); + private List? _accessibleChildren; + + /// + /// Gets or sets the accessibility name for screen readers. + /// + public string? SemanticName { get; set; } + + /// + /// Gets or sets the accessibility description for screen readers. + /// + public string? SemanticDescription { get; set; } + + /// + /// Gets or sets the accessibility hint for screen readers. + /// + public string? SemanticHint { get; set; } + + /// + /// Gets the accessibility service instance. + /// + protected static IAccessibilityService? AccessibilityService + { + get + { + InitializeAccessibilityService(); + return _accessibilityService; + } + } + + private static void InitializeAccessibilityService() + { + if (_accessibilityInitialized) return; + _accessibilityInitialized = true; + + try + { + _accessibilityService = AccessibilityServiceFactory.Instance; + _accessibilityService?.Initialize(); + } + catch + { + // Ignore errors - accessibility is optional + } + } + + /// + /// Registers this view with the accessibility service. + /// + protected void RegisterAccessibility() + { + AccessibilityService?.Register(this); + } + + /// + /// Unregisters this view from the accessibility service. + /// + protected void UnregisterAccessibility() + { + AccessibilityService?.Unregister(this); + } + + /// + /// Announces text to screen readers. + /// + protected void AnnounceToScreenReader(string text, AnnouncementPriority priority = AnnouncementPriority.Polite) + { + AccessibilityService?.Announce(text, priority); + } + + // IAccessible implementation + string IAccessible.AccessibleId => _accessibleId; + + string IAccessible.AccessibleName => SemanticName ?? GetDefaultAccessibleName(); + + string IAccessible.AccessibleDescription => SemanticDescription ?? SemanticHint ?? string.Empty; + + AccessibleRole IAccessible.Role => GetAccessibleRole(); + + AccessibleStates IAccessible.States => GetAccessibleStates(); + + IAccessible? IAccessible.Parent => Parent as IAccessible; + + IReadOnlyList IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren(); + + AccessibleRect IAccessible.Bounds => new AccessibleRect( + (int)ScreenBounds.Left, + (int)ScreenBounds.Top, + (int)ScreenBounds.Width, + (int)ScreenBounds.Height); + + IReadOnlyList IAccessible.Actions => GetAccessibleActions(); + + double? IAccessible.Value => GetAccessibleValue(); + double? IAccessible.MinValue => GetAccessibleMinValue(); + double? IAccessible.MaxValue => GetAccessibleMaxValue(); + + bool IAccessible.DoAction(string actionName) => DoAccessibleAction(actionName); + bool IAccessible.SetValue(double value) => SetAccessibleValue(value); + + /// + /// Gets the default accessible name based on view content. + /// + protected virtual string GetDefaultAccessibleName() => string.Empty; + + /// + /// Gets the accessible role for this view. + /// + protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown; + + /// + /// Gets the current accessible states. + /// + protected virtual AccessibleStates GetAccessibleStates() + { + var states = AccessibleStates.None; + if (IsVisible) states |= AccessibleStates.Visible; + if (IsEnabled) states |= AccessibleStates.Enabled; + if (IsFocused) states |= AccessibleStates.Focused; + if (IsFocusable) states |= AccessibleStates.Focusable; + return states; + } + + /// + /// Gets the accessible children of this view. + /// + protected virtual List GetAccessibleChildren() + { + var children = new List(); + foreach (var child in Children) + { + if (child is IAccessible accessible) + { + children.Add(accessible); + } + } + return children; + } + + /// + /// Gets the available accessible actions. + /// + protected virtual IReadOnlyList GetAccessibleActions() + { + return Array.Empty(); + } + + /// + /// Performs an accessible action. + /// + protected virtual bool DoAccessibleAction(string actionName) => false; + + /// + /// Gets the accessible value (for sliders, progress bars, etc.). + /// + protected virtual double? GetAccessibleValue() => null; + + /// + /// Gets the minimum accessible value. + /// + protected virtual double? GetAccessibleMinValue() => null; + + /// + /// Gets the maximum accessible value. + /// + protected virtual double? GetAccessibleMaxValue() => null; + + /// + /// Sets the accessible value. + /// + protected virtual bool SetAccessibleValue(double value) => false; + + #endregion + #region BindableProperties ///