Missing items

This commit is contained in:
2026-01-17 02:23:05 +00:00
parent 523de9d8b9
commit 47a5fc8c01
7 changed files with 890 additions and 72 deletions

View File

@@ -54,6 +54,24 @@ public static class LinuxMauiAppBuilderExtensions
builder.Services.TryAddSingleton<IBrowser, BrowserService>();
builder.Services.TryAddSingleton<IEmail, EmailService>();
// Register theming and accessibility services
builder.Services.TryAddSingleton<SystemThemeService>();
builder.Services.TryAddSingleton<HighContrastService>();
// Register accessibility service
builder.Services.TryAddSingleton<IAccessibilityService>(_ => AccessibilityServiceFactory.Instance);
// Register input method service
builder.Services.TryAddSingleton<IInputMethodService>(_ => InputMethodServiceFactory.Instance);
// Register font fallback manager
builder.Services.TryAddSingleton(_ => FontFallbackManager.Instance);
// Register additional Linux-specific services
builder.Services.TryAddSingleton<FolderPickerService>();
builder.Services.TryAddSingleton<NotificationService>();
builder.Services.TryAddSingleton<SystemTrayService>();
// Register GTK host service
builder.Services.TryAddSingleton(_ => GtkHostService.Instance);

View File

@@ -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;

View File

@@ -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<AssemblyInformationalVersionAttribute>()?.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

View File

@@ -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;
/// <summary>
/// Skia-rendered multiline text editor control with full XAML styling support.
/// Implements IInputContext for IME (Input Method Editor) support.
/// </summary>
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;
}
/// <summary>
/// Determines if text should be rendered right-to-left based on FlowDirection.
/// </summary>
private bool IsRightToLeft()
{
return FlowDirection == FlowDirection.RightToLeft;
}
/// <summary>
/// Gets the horizontal alignment accounting for FlowDirection.
/// </summary>
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
/// <summary>
/// Gets or sets the text for IME context.
/// </summary>
string IInputContext.Text
{
get => Text;
set => Text = value;
}
/// <summary>
/// Gets or sets the cursor position for IME context.
/// </summary>
int IInputContext.CursorPosition
{
get => _cursorPosition;
set => CursorPosition = value;
}
/// <summary>
/// Gets the selection start for IME context.
/// </summary>
int IInputContext.SelectionStart => _selectionStart;
/// <summary>
/// Gets the selection length for IME context.
/// </summary>
int IInputContext.SelectionLength => _selectionLength;
/// <summary>
/// Called when IME commits text.
/// </summary>
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();
}
/// <summary>
/// Called when IME pre-edit (composition) text changes.
/// </summary>
public void OnPreEditChanged(string preEditText, int cursorPosition)
{
_preEditText = preEditText ?? string.Empty;
_preEditCursorPosition = cursorPosition;
Invalidate();
}
/// <summary>
/// Called when IME pre-edit ends (cancelled or committed).
/// </summary>
public void OnPreEditEnded()
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
Invalidate();
}
#endregion
/// <summary>
/// Event raised when text changes.
/// </summary>
@@ -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
/// <summary>
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
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);
}
}
/// <summary>
/// Draws underline for IME pre-edit (composition) text.
/// </summary>
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);
}
/// <summary>
/// Updates the IME cursor location for candidate window positioning.
/// </summary>
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)

View File

@@ -13,8 +13,9 @@ namespace Microsoft.Maui.Platform;
/// <summary>
/// Skia-rendered text entry control with full XAML styling and data binding support.
/// Implements IInputContext for IME (Input Method Editor) support.
/// </summary>
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;
/// <summary>
/// Event raised when text changes.
/// </summary>
@@ -675,8 +681,96 @@ public class SkiaEntry : SkiaView
public SkiaEntry()
{
IsFocusable = true;
// Get IME service from factory
_inputMethodService = InputMethodServiceFactory.Instance;
}
#region IInputContext Implementation
/// <summary>
/// Gets or sets the text for IME context.
/// </summary>
string IInputContext.Text
{
get => Text;
set => Text = value;
}
/// <summary>
/// Gets or sets the cursor position for IME context.
/// </summary>
int IInputContext.CursorPosition
{
get => _cursorPosition;
set => CursorPosition = value;
}
/// <summary>
/// Gets the selection start for IME context.
/// </summary>
int IInputContext.SelectionStart => _selectionStart;
/// <summary>
/// Gets the selection length for IME context.
/// </summary>
int IInputContext.SelectionLength => _selectionLength;
/// <summary>
/// Called when IME commits text.
/// </summary>
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();
}
/// <summary>
/// Called when IME pre-edit (composition) text changes.
/// </summary>
public void OnPreEditChanged(string preEditText, int cursorPosition)
{
_preEditText = preEditText ?? string.Empty;
_preEditCursorPosition = cursorPosition;
ResetCursorBlink();
Invalidate();
}
/// <summary>
/// Called when IME pre-edit ends (cancelled or committed).
/// </summary>
public void OnPreEditEnded()
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
Invalidate();
}
#endregion
/// <summary>
/// Converts a MAUI Color to SkiaSharp SKColor for rendering.
/// </summary>
@@ -714,6 +808,30 @@ public class SkiaEntry : SkiaView
return string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily;
}
/// <summary>
/// Determines if text should be rendered right-to-left based on FlowDirection.
/// </summary>
private bool IsRightToLeft()
{
return FlowDirection == FlowDirection.RightToLeft;
}
/// <summary>
/// Gets the horizontal alignment accounting for FlowDirection.
/// </summary>
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);
}
/// <summary>
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
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);
}
}
/// <summary>
/// Draws underline for IME pre-edit (composition) text.
/// </summary>
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;
}
/// <summary>
/// Updates the IME cursor location for candidate window positioning.
/// </summary>
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)

View File

@@ -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);
}
/// <summary>
/// Determines if text should be rendered right-to-left based on FlowDirection.
/// </summary>
private bool IsRightToLeft()
{
return FlowDirection == FlowDirection.RightToLeft;
}
/// <summary>
/// Gets the effective horizontal alignment for the given alignment,
/// accounting for FlowDirection (RTL flips Start/End).
/// </summary>
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;
}
}
}
/// <summary>
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
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);
}
}
/// <summary>
/// Draws formatted span text with font fallback for emoji, CJK, and other scripts.
/// </summary>
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)

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<SKCanvas> 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;
/// <summary>
/// Gets whether high contrast mode is enabled.
/// </summary>
public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
/// <summary>
/// Gets the current high contrast colors, or default colors if not in high contrast mode.
/// </summary>
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<IAccessible>? _accessibleChildren;
/// <summary>
/// Gets or sets the accessibility name for screen readers.
/// </summary>
public string? SemanticName { get; set; }
/// <summary>
/// Gets or sets the accessibility description for screen readers.
/// </summary>
public string? SemanticDescription { get; set; }
/// <summary>
/// Gets or sets the accessibility hint for screen readers.
/// </summary>
public string? SemanticHint { get; set; }
/// <summary>
/// Gets the accessibility service instance.
/// </summary>
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
}
}
/// <summary>
/// Registers this view with the accessibility service.
/// </summary>
protected void RegisterAccessibility()
{
AccessibilityService?.Register(this);
}
/// <summary>
/// Unregisters this view from the accessibility service.
/// </summary>
protected void UnregisterAccessibility()
{
AccessibilityService?.Unregister(this);
}
/// <summary>
/// Announces text to screen readers.
/// </summary>
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> IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
AccessibleRect IAccessible.Bounds => new AccessibleRect(
(int)ScreenBounds.Left,
(int)ScreenBounds.Top,
(int)ScreenBounds.Width,
(int)ScreenBounds.Height);
IReadOnlyList<AccessibleAction> 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);
/// <summary>
/// Gets the default accessible name based on view content.
/// </summary>
protected virtual string GetDefaultAccessibleName() => string.Empty;
/// <summary>
/// Gets the accessible role for this view.
/// </summary>
protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
/// <summary>
/// Gets the current accessible states.
/// </summary>
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;
}
/// <summary>
/// Gets the accessible children of this view.
/// </summary>
protected virtual List<IAccessible> GetAccessibleChildren()
{
var children = new List<IAccessible>();
foreach (var child in Children)
{
if (child is IAccessible accessible)
{
children.Add(accessible);
}
}
return children;
}
/// <summary>
/// Gets the available accessible actions.
/// </summary>
protected virtual IReadOnlyList<AccessibleAction> GetAccessibleActions()
{
return Array.Empty<AccessibleAction>();
}
/// <summary>
/// Performs an accessible action.
/// </summary>
protected virtual bool DoAccessibleAction(string actionName) => false;
/// <summary>
/// Gets the accessible value (for sliders, progress bars, etc.).
/// </summary>
protected virtual double? GetAccessibleValue() => null;
/// <summary>
/// Gets the minimum accessible value.
/// </summary>
protected virtual double? GetAccessibleMinValue() => null;
/// <summary>
/// Gets the maximum accessible value.
/// </summary>
protected virtual double? GetAccessibleMaxValue() => null;
/// <summary>
/// Sets the accessible value.
/// </summary>
protected virtual bool SetAccessibleValue(double value) => false;
#endregion
#region BindableProperties
/// <summary>