Files
logikonline 077abc2feb refactor: split large files into partial classes by concern
Split LinuxApplication into Input and Lifecycle partials. Extract SkiaView into Accessibility, Drawing, and Input partials. Split SkiaEntry and SkiaEditor into Drawing and Input partials. Extract TextRenderingHelper from SkiaRenderingEngine. Create dedicated files for SkiaAbsoluteLayout, SkiaGrid, and SkiaStackLayout. This reduces file sizes from 40K+ lines to manageable units organized by responsibility.
2026-03-06 22:36:23 -05:00

1240 lines
39 KiB
C#

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
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;
/// <summary>
/// Skia-rendered label control matching the .NET MAUI Label API.
/// </summary>
public class SkiaLabel : SkiaView
{
#region BindableProperties
/// <summary>
/// Bindable property for Text.
/// </summary>
public static readonly BindableProperty TextProperty = BindableProperty.Create(
nameof(Text),
typeof(string),
typeof(SkiaLabel),
string.Empty,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for TextColor.
/// Default is null to match MAUI Label.TextColor (falls back to platform default).
/// </summary>
public static readonly BindableProperty TextColorProperty = BindableProperty.Create(
nameof(TextColor),
typeof(Color),
typeof(SkiaLabel),
null,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for FontFamily.
/// </summary>
public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create(
nameof(FontFamily),
typeof(string),
typeof(SkiaLabel),
string.Empty,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for FontSize.
/// </summary>
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(
nameof(FontSize),
typeof(double),
typeof(SkiaLabel),
14.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for FontAttributes.
/// </summary>
public static readonly BindableProperty FontAttributesProperty = BindableProperty.Create(
nameof(FontAttributes),
typeof(FontAttributes),
typeof(SkiaLabel),
FontAttributes.None,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for FontAutoScalingEnabled.
/// </summary>
public static readonly BindableProperty FontAutoScalingEnabledProperty = BindableProperty.Create(
nameof(FontAutoScalingEnabled),
typeof(bool),
typeof(SkiaLabel),
true,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for CharacterSpacing.
/// </summary>
public static readonly BindableProperty CharacterSpacingProperty = BindableProperty.Create(
nameof(CharacterSpacing),
typeof(double),
typeof(SkiaLabel),
0.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).InvalidateMeasure());
/// <summary>
/// Bindable property for TextDecorations.
/// </summary>
public static readonly BindableProperty TextDecorationsProperty = BindableProperty.Create(
nameof(TextDecorations),
typeof(TextDecorations),
typeof(SkiaLabel),
TextDecorations.None,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for HorizontalTextAlignment.
/// </summary>
public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create(
nameof(HorizontalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Start,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for VerticalTextAlignment.
/// Default is Start to match MAUI Label.VerticalTextAlignment.
/// </summary>
public static readonly BindableProperty VerticalTextAlignmentProperty = BindableProperty.Create(
nameof(VerticalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Start,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for LineBreakMode.
/// </summary>
public static readonly BindableProperty LineBreakModeProperty = BindableProperty.Create(
nameof(LineBreakMode),
typeof(LineBreakMode),
typeof(SkiaLabel),
LineBreakMode.TailTruncation,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for MaxLines.
/// </summary>
public static readonly BindableProperty MaxLinesProperty = BindableProperty.Create(
nameof(MaxLines),
typeof(int),
typeof(SkiaLabel),
0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for LineHeight.
/// Default is -1 to match MAUI Label.LineHeight (platform default).
/// </summary>
public static readonly BindableProperty LineHeightProperty = BindableProperty.Create(
nameof(LineHeight),
typeof(double),
typeof(SkiaLabel),
-1.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for TextTransform.
/// </summary>
public static readonly BindableProperty TextTransformProperty = BindableProperty.Create(
nameof(TextTransform),
typeof(TextTransform),
typeof(SkiaLabel),
TextTransform.Default,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for TextType.
/// </summary>
public static readonly BindableProperty TextTypeProperty = BindableProperty.Create(
nameof(TextType),
typeof(TextType),
typeof(SkiaLabel),
TextType.Text,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for Padding.
/// </summary>
public static new readonly BindableProperty PaddingProperty = BindableProperty.Create(
nameof(Padding),
typeof(Thickness),
typeof(SkiaLabel),
new Thickness(0),
propertyChanged: (b, o, n) => ((SkiaLabel)b).InvalidateMeasure());
/// <summary>
/// Bindable property for FormattedText.
/// </summary>
public static readonly BindableProperty FormattedTextProperty = BindableProperty.Create(
nameof(FormattedText),
typeof(FormattedString),
typeof(SkiaLabel),
null,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFormattedTextChanged((FormattedString?)o, (FormattedString?)n));
#endregion
#region Properties
/// <summary>
/// Gets or sets the text content.
/// </summary>
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
/// <summary>
/// Gets or sets the text color.
/// Null means use platform default (black on Linux).
/// </summary>
public Color? TextColor
{
get => (Color?)GetValue(TextColorProperty);
set => SetValue(TextColorProperty, value);
}
/// <summary>
/// Gets or sets the font family.
/// </summary>
public string FontFamily
{
get => (string)GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
/// <summary>
/// Gets or sets the font size.
/// </summary>
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
/// <summary>
/// Gets or sets the font attributes.
/// </summary>
public FontAttributes FontAttributes
{
get => (FontAttributes)GetValue(FontAttributesProperty);
set => SetValue(FontAttributesProperty, value);
}
/// <summary>
/// Gets or sets whether font auto-scaling is enabled.
/// </summary>
public bool FontAutoScalingEnabled
{
get => (bool)GetValue(FontAutoScalingEnabledProperty);
set => SetValue(FontAutoScalingEnabledProperty, value);
}
/// <summary>
/// Gets or sets the character spacing.
/// </summary>
public double CharacterSpacing
{
get => (double)GetValue(CharacterSpacingProperty);
set => SetValue(CharacterSpacingProperty, value);
}
/// <summary>
/// Gets or sets the text decorations.
/// </summary>
public TextDecorations TextDecorations
{
get => (TextDecorations)GetValue(TextDecorationsProperty);
set => SetValue(TextDecorationsProperty, value);
}
/// <summary>
/// Gets or sets the horizontal text alignment.
/// </summary>
public TextAlignment HorizontalTextAlignment
{
get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
set => SetValue(HorizontalTextAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the vertical text alignment.
/// </summary>
public TextAlignment VerticalTextAlignment
{
get => (TextAlignment)GetValue(VerticalTextAlignmentProperty);
set => SetValue(VerticalTextAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the line break mode.
/// </summary>
public LineBreakMode LineBreakMode
{
get => (LineBreakMode)GetValue(LineBreakModeProperty);
set => SetValue(LineBreakModeProperty, value);
}
/// <summary>
/// Gets or sets the maximum number of lines.
/// </summary>
public int MaxLines
{
get => (int)GetValue(MaxLinesProperty);
set => SetValue(MaxLinesProperty, value);
}
/// <summary>
/// Gets or sets the line height multiplier.
/// </summary>
public double LineHeight
{
get => (double)GetValue(LineHeightProperty);
set => SetValue(LineHeightProperty, value);
}
/// <summary>
/// Gets or sets the text transform.
/// </summary>
public TextTransform TextTransform
{
get => (TextTransform)GetValue(TextTransformProperty);
set => SetValue(TextTransformProperty, value);
}
/// <summary>
/// Gets or sets the text type.
/// </summary>
public TextType TextType
{
get => (TextType)GetValue(TextTypeProperty);
set => SetValue(TextTypeProperty, value);
}
/// <summary>
/// Gets or sets the padding.
/// </summary>
public new Thickness Padding
{
get => (Thickness)GetValue(PaddingProperty);
set => SetValue(PaddingProperty, value);
}
/// <summary>
/// Gets or sets the formatted text.
/// </summary>
public FormattedString? FormattedText
{
get => (FormattedString?)GetValue(FormattedTextProperty);
set => SetValue(FormattedTextProperty, value);
}
#endregion
#region Selection State
private int _selectionStart = -1;
private int _selectionLength = 0;
private bool _isSelecting = false;
private DateTime _lastClickTime = DateTime.MinValue;
private float _lastClickX;
private const double DoubleClickThresholdMs = 400;
/// <summary>
/// Gets or sets whether text selection is enabled.
/// </summary>
public bool IsTextSelectionEnabled { get; set; } = true;
/// <summary>
/// Gets the currently selected text.
/// </summary>
public string SelectedText
{
get
{
if (_selectionStart < 0 || _selectionLength == 0) return string.Empty;
var text = GetDisplayText();
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
var length = Math.Abs(_selectionLength);
if (start < 0 || start >= text.Length) return string.Empty;
return text.Substring(start, Math.Min(length, text.Length - start));
}
}
#endregion
#region Events
/// <summary>
/// Occurs when the label is tapped.
/// </summary>
public event EventHandler? Tapped;
/// <summary>
/// Raises the Tapped event.
/// </summary>
protected virtual void OnTapped()
{
Tapped?.Invoke(this, EventArgs.Empty);
}
public override void OnPointerPressed(PointerEventArgs e)
{
base.OnPointerPressed(e);
if (!IsTextSelectionEnabled || string.IsNullOrEmpty(Text)) return;
var text = GetDisplayText();
if (string.IsNullOrEmpty(text)) return;
// Calculate character position from click
var screenBounds = ScreenBounds;
var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left;
var charIndex = GetCharacterIndexAtX(clickX);
// Check for double-click (select word)
var now = DateTime.UtcNow;
var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
var distanceFromLastClick = Math.Abs(e.X - _lastClickX);
if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
{
// Double-click: select word
SelectWordAt(charIndex);
_lastClickTime = DateTime.MinValue;
_isSelecting = false;
}
else
{
// Single click: start selection
_selectionStart = charIndex;
_selectionLength = 0;
_isSelecting = true;
_lastClickTime = now;
_lastClickX = e.X;
}
Invalidate();
}
public override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (!IsTextSelectionEnabled || !_isSelecting) return;
var text = GetDisplayText();
if (string.IsNullOrEmpty(text)) return;
var screenBounds = ScreenBounds;
var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left;
var charIndex = GetCharacterIndexAtX(clickX);
_selectionLength = charIndex - _selectionStart;
Invalidate();
}
public override void OnPointerReleased(PointerEventArgs e)
{
base.OnPointerReleased(e);
if (_isSelecting && _selectionLength == 0)
{
// No drag happened, it's a tap
OnTapped();
}
_isSelecting = false;
}
public override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (!IsTextSelectionEnabled) return;
// Ctrl+A: Select All
if (e.Key == Key.A && e.Modifiers.HasFlag(KeyModifiers.Control))
{
SelectAll();
e.Handled = true;
}
// Ctrl+C: Copy
else if (e.Key == Key.C && e.Modifiers.HasFlag(KeyModifiers.Control))
{
CopyToClipboard();
e.Handled = true;
}
}
/// <summary>
/// Selects all text in the label.
/// </summary>
public void SelectAll()
{
var text = GetDisplayText();
_selectionStart = 0;
_selectionLength = text.Length;
Invalidate();
}
/// <summary>
/// Clears the current selection.
/// </summary>
public void ClearSelection()
{
_selectionStart = -1;
_selectionLength = 0;
Invalidate();
}
private void SelectWordAt(int charIndex)
{
var text = GetDisplayText();
if (string.IsNullOrEmpty(text) || charIndex < 0 || charIndex >= text.Length) return;
int start = charIndex;
int end = charIndex;
// Move start backwards to beginning of word
while (start > 0 && IsWordChar(text[start - 1]))
start--;
// Move end forwards to end of word
while (end < text.Length && IsWordChar(text[end]))
end++;
_selectionStart = start;
_selectionLength = end - start;
}
private static bool IsWordChar(char c)
{
return char.IsLetterOrDigit(c) || c == '_';
}
private int GetCharacterIndexAtX(float x)
{
var text = GetDisplayText();
if (string.IsNullOrEmpty(text)) return 0;
float fontSize = FontSize > 0 ? (float)FontSize : 14f;
var fontFamily = string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily;
using var font = new SKFont(
SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, GetFontStyle()) ?? SKTypeface.Default,
fontSize);
using var paint = new SKPaint(font);
for (int i = 0; i <= text.Length; i++)
{
var substring = text.Substring(0, i);
var width = paint.MeasureText(substring);
if (CharacterSpacing != 0 && i > 0)
{
width += (float)(CharacterSpacing * i);
}
if (width > x)
{
return i > 0 ? i - 1 : 0;
}
}
return text.Length;
}
private void CopyToClipboard()
{
var selectedText = SelectedText;
if (!string.IsNullOrEmpty(selectedText))
{
SystemClipboard.SetText(selectedText);
}
}
#endregion
#region Private Methods
private void OnTextChanged()
{
InvalidateMeasure();
Invalidate();
}
private void OnFontChanged()
{
InvalidateMeasure();
Invalidate();
}
private void OnFormattedTextChanged(FormattedString? oldValue, FormattedString? newValue)
{
if (oldValue != null)
{
oldValue.PropertyChanged -= OnFormattedTextPropertyChanged;
}
if (newValue != null)
{
newValue.PropertyChanged += OnFormattedTextPropertyChanged;
}
OnTextChanged();
}
private void OnFormattedTextPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
OnTextChanged();
}
private SKColor ToSKColor(Color? color) => TextRenderingHelper.ToSKColor(color, SkiaTheme.TextPrimarySK);
private string GetDisplayText()
{
var text = Text ?? string.Empty;
// Handle TextType.Html by stripping tags (basic implementation)
if (TextType == TextType.Html)
{
text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]*>", "");
}
// Apply text transform
return TextTransform switch
{
TextTransform.Uppercase => text.ToUpperInvariant(),
TextTransform.Lowercase => text.ToLowerInvariant(),
_ => text
};
}
private SKFontStyle GetFontStyle() => TextRenderingHelper.GetFontStyle(FontAttributes);
/// <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
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
var padding = Padding;
var contentBounds = new SKRect(
bounds.Left + (float)padding.Left,
bounds.Top + (float)padding.Top,
bounds.Right - (float)padding.Right,
bounds.Bottom - (float)padding.Bottom);
// If we have FormattedText, draw that instead
if (FormattedText != null && FormattedText.Spans.Count > 0)
{
DrawFormattedText(canvas, contentBounds);
return;
}
string displayText = GetDisplayText();
if (string.IsNullOrEmpty(displayText)) return;
float fontSize = FontSize > 0 ? (float)FontSize : 14f;
var fontFamily = string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily;
using var font = new SKFont(
SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, GetFontStyle()) ?? SKTypeface.Default,
fontSize);
using var paint = new SKPaint(font)
{
Color = ToSKColor(TextColor),
IsAntialias = true
};
// Check if we need multi-line rendering
bool needsMultiLine = LineBreakMode == LineBreakMode.WordWrap ||
LineBreakMode == LineBreakMode.CharacterWrap ||
MaxLines > 1 ||
displayText.Contains('\n');
if (needsMultiLine)
{
DrawMultiLineText(canvas, paint, font, contentBounds, displayText);
}
else
{
DrawSingleLineText(canvas, paint, contentBounds, displayText);
}
}
private void DrawSingleLineText(SKCanvas canvas, SKPaint paint, SKRect bounds, string text)
{
var textBounds = new SKRect();
paint.MeasureText(text, ref textBounds);
// Apply truncation if needed
string displayText = text;
float availableWidth = bounds.Width;
if (textBounds.Width > availableWidth && LineBreakMode != LineBreakMode.NoWrap)
{
displayText = TruncateText(text, paint, availableWidth, LineBreakMode);
paint.MeasureText(displayText, ref textBounds);
}
// Account for character spacing in measurement
float textWidth = textBounds.Width;
if (CharacterSpacing != 0 && displayText.Length > 1)
{
textWidth += (float)(CharacterSpacing * (displayText.Length - 1));
}
// Calculate position based on alignment and FlowDirection
float x = GetHorizontalPosition(HorizontalTextAlignment, bounds.Left, bounds.Right, textWidth);
float y = VerticalTextAlignment switch
{
TextAlignment.Start => bounds.Top - textBounds.Top,
TextAlignment.Center => bounds.MidY - textBounds.MidY,
TextAlignment.End => bounds.Bottom - textBounds.Bottom,
_ => bounds.MidY - textBounds.MidY
};
// Draw selection highlight if applicable
if (_selectionStart >= 0 && _selectionLength != 0)
{
DrawSelectionHighlight(canvas, paint, x, y, displayText, textBounds);
}
DrawTextWithSpacing(canvas, displayText, x, y, paint);
DrawTextDecorations(canvas, paint, x, y, textBounds);
}
private void DrawSelectionHighlight(SKCanvas canvas, SKPaint paint, float x, float y, string text, SKRect textBounds)
{
var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength);
var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength);
// Clamp to text length
selStart = Math.Max(0, Math.Min(selStart, text.Length));
selEnd = Math.Max(0, Math.Min(selEnd, text.Length));
if (selStart >= selEnd) return;
var textToStart = text.Substring(0, selStart);
var textToEnd = text.Substring(0, selEnd);
float startX = x + paint.MeasureText(textToStart);
float endX = x + paint.MeasureText(textToEnd);
if (CharacterSpacing != 0)
{
startX += (float)(CharacterSpacing * selStart);
endX += (float)(CharacterSpacing * selEnd);
}
using var selectionPaint = new SKPaint
{
Color = SkiaTheme.PrimaryLightSK,
Style = SKPaintStyle.Fill
};
float selectionTop = y + textBounds.Top;
float selectionBottom = y + textBounds.Bottom;
canvas.DrawRect(new SKRect(startX, selectionTop, endX, selectionBottom), selectionPaint);
}
private void DrawMultiLineText(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds, string text)
{
// LineHeight -1 means platform default (use 1.0 multiplier)
double effectiveLineHeight = LineHeight < 0 ? 1.0 : LineHeight;
float lineHeight = (float)(FontSize * effectiveLineHeight);
float y = bounds.Top;
int lineCount = 0;
var lines = WrapText(text, paint, bounds.Width);
foreach (var line in lines)
{
if (MaxLines > 0 && lineCount >= MaxLines) break;
if (y + lineHeight > bounds.Bottom && MaxLines == 0) break;
var textBounds = new SKRect();
paint.MeasureText(line, ref textBounds);
float textWidth = textBounds.Width;
if (CharacterSpacing != 0 && line.Length > 1)
{
textWidth += (float)(CharacterSpacing * (line.Length - 1));
}
// Use FlowDirection-aware positioning
float x = GetHorizontalPosition(HorizontalTextAlignment, bounds.Left, bounds.Right, textWidth);
float textY = y - textBounds.Top;
DrawTextWithSpacing(canvas, line, x, textY, paint);
DrawTextDecorations(canvas, paint, x, textY, textBounds);
y += lineHeight;
lineCount++;
}
}
private void DrawTextWithSpacing(SKCanvas canvas, string text, float x, float y, SKPaint paint)
{
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)
=> TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, FontSize > 0 ? (float)FontSize : 14f);
/// <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);
}
}
private void DrawTextDecorations(SKCanvas canvas, SKPaint paint, float x, float y, SKRect textBounds)
{
if (TextDecorations == TextDecorations.None) return;
using var linePaint = new SKPaint
{
Color = paint.Color,
StrokeWidth = 1,
IsAntialias = true
};
float textWidth = textBounds.Width;
if (CharacterSpacing != 0)
{
// Approximate width adjustment for decorations
textWidth += (float)(CharacterSpacing * Math.Max(0, Text?.Length - 1 ?? 0));
}
if (TextDecorations.HasFlag(TextDecorations.Underline))
{
float underlineY = y + 2;
canvas.DrawLine(x, underlineY, x + textWidth, underlineY, linePaint);
}
if (TextDecorations.HasFlag(TextDecorations.Strikethrough))
{
float strikeY = y - textBounds.Height / 3;
canvas.DrawLine(x, strikeY, x + textWidth, strikeY, linePaint);
}
}
private void DrawFormattedText(SKCanvas canvas, SKRect bounds)
{
if (FormattedText == null) return;
float x = bounds.Left;
float y = bounds.Top;
// LineHeight -1 means platform default (use 1.0 multiplier)
double effectiveLineHeight = LineHeight < 0 ? 1.0 : LineHeight;
float lineHeight = (float)(FontSize * effectiveLineHeight);
float fontSize = FontSize > 0 ? (float)FontSize : 14f;
// Calculate baseline for first line
using var measureFont = new SKFont(SKTypeface.Default, fontSize);
using var measurePaint = new SKPaint(measureFont);
var metrics = measurePaint.FontMetrics;
y -= metrics.Ascent;
foreach (var span in FormattedText.Spans)
{
if (string.IsNullOrEmpty(span.Text)) continue;
// Get span-specific styling
var spanFontSize = span.FontSize > 0 ? (float)span.FontSize : fontSize;
var spanFontFamily = !string.IsNullOrEmpty(span.FontFamily) ? span.FontFamily :
(!string.IsNullOrEmpty(FontFamily) ? FontFamily : "Sans");
bool isBold = span.FontAttributes.HasFlag(FontAttributes.Bold) ||
FontAttributes.HasFlag(FontAttributes.Bold);
bool isItalic = span.FontAttributes.HasFlag(FontAttributes.Italic) ||
FontAttributes.HasFlag(FontAttributes.Italic);
var fontStyle = new SKFontStyle(
isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
using var font = new SKFont(
SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(spanFontFamily, fontStyle) ?? SKTypeface.Default,
spanFontSize);
var spanColor = span.TextColor ?? TextColor;
using var paint = new SKPaint(font)
{
Color = ToSKColor(spanColor),
IsAntialias = true
};
var textBounds = new SKRect();
paint.MeasureText(span.Text, ref textBounds);
// Check if we need to wrap to next line
if (x + textBounds.Width > bounds.Right && x > bounds.Left)
{
x = bounds.Left;
y += lineHeight;
}
// 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)
{
using var linePaint = new SKPaint { Color = paint.Color, StrokeWidth = 1, IsAntialias = true };
if (span.TextDecorations.HasFlag(TextDecorations.Underline))
{
canvas.DrawLine(x, y + 2, x + textBounds.Width, y + 2, linePaint);
}
if (span.TextDecorations.HasFlag(TextDecorations.Strikethrough))
{
float strikeY = y - textBounds.Height / 3;
canvas.DrawLine(x, strikeY, x + textBounds.Width, strikeY, linePaint);
}
}
x += textBounds.Width;
}
}
private string TruncateText(string text, SKPaint paint, float maxWidth, LineBreakMode mode)
{
if (string.IsNullOrEmpty(text)) return text;
var bounds = new SKRect();
paint.MeasureText(text, ref bounds);
if (bounds.Width <= maxWidth) return text;
string ellipsis = "...";
float ellipsisWidth = paint.MeasureText(ellipsis);
switch (mode)
{
case LineBreakMode.HeadTruncation:
for (int i = 1; i < text.Length; i++)
{
string truncated = ellipsis + text.Substring(i);
if (paint.MeasureText(truncated) <= maxWidth)
return truncated;
}
return ellipsis;
case LineBreakMode.MiddleTruncation:
int half = text.Length / 2;
for (int i = 0; i < half; i++)
{
string truncated = text.Substring(0, half - i) + ellipsis + text.Substring(half + i);
if (paint.MeasureText(truncated) <= maxWidth)
return truncated;
}
return ellipsis;
case LineBreakMode.TailTruncation:
default:
for (int i = text.Length - 1; i > 0; i--)
{
string truncated = text.Substring(0, i) + ellipsis;
if (paint.MeasureText(truncated) <= maxWidth)
return truncated;
}
return ellipsis;
}
}
private List<string> WrapText(string text, SKPaint paint, float maxWidth)
{
var lines = new List<string>();
if (string.IsNullOrEmpty(text)) return lines;
// Split by existing newlines first
var paragraphs = text.Split('\n');
foreach (var paragraph in paragraphs)
{
if (string.IsNullOrEmpty(paragraph))
{
lines.Add(string.Empty);
continue;
}
// Check if the entire paragraph fits on one line - no need to wrap
// Use small tolerance to account for floating point precision
float paragraphWidth = paint.MeasureText(paragraph);
if (paragraphWidth <= maxWidth + 1.0f)
{
lines.Add(paragraph);
continue;
}
if (LineBreakMode == LineBreakMode.CharacterWrap)
{
WrapByCharacter(paragraph, paint, maxWidth, lines);
}
else
{
WrapByWord(paragraph, paint, maxWidth, lines);
}
}
return lines;
}
private void WrapByWord(string text, SKPaint paint, float maxWidth, List<string> lines)
{
var words = text.Split(' ');
string currentLine = "";
foreach (var word in words)
{
string testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
float width = paint.MeasureText(testLine);
if (width > maxWidth && !string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
currentLine = word;
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
}
}
private void WrapByCharacter(string text, SKPaint paint, float maxWidth, List<string> lines)
{
string currentLine = "";
foreach (char c in text)
{
string testLine = currentLine + c;
float width = paint.MeasureText(testLine);
if (width > maxWidth && !string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
currentLine = c.ToString();
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
}
}
#endregion
#region Measurement
protected override Size MeasureOverride(Size availableSize)
{
var padding = Padding;
double paddingH = padding.Left + padding.Right;
double paddingV = padding.Top + padding.Bottom;
string displayText = GetDisplayText();
if (string.IsNullOrEmpty(displayText) && (FormattedText == null || FormattedText.Spans.Count == 0))
{
return new Size(paddingH, paddingV + FontSize);
}
float fontSize = FontSize > 0 ? (float)FontSize : 14f;
var fontFamily = string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily;
using var font = new SKFont(
SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, GetFontStyle()) ?? SKTypeface.Default,
fontSize);
using var paint = new SKPaint(font);
double width, height;
// LineHeight -1 means platform default (use 1.0 multiplier)
double effectiveLineHeight = LineHeight < 0 ? 1.0 : LineHeight;
if (FormattedText != null && FormattedText.Spans.Count > 0)
{
// Measure formatted text
width = 0;
height = fontSize * effectiveLineHeight;
foreach (var span in FormattedText.Spans)
{
if (!string.IsNullOrEmpty(span.Text))
{
width += paint.MeasureText(span.Text);
}
}
}
else
{
// Use advance width (paint.MeasureText return value) not bounding box width
// This must match what WrapText uses for consistency
var textBounds = new SKRect();
paint.MeasureText(displayText, ref textBounds);
width = paint.MeasureText(displayText); // Advance width, not textBounds.Width
height = textBounds.Height;
// Account for character spacing
if (CharacterSpacing != 0 && displayText.Length > 1)
{
width += CharacterSpacing * (displayText.Length - 1);
}
// Account for multi-line
if (displayText.Contains('\n') || MaxLines > 1)
{
var lines = displayText.Split('\n');
int lineCount = MaxLines > 0 ? Math.Min(lines.Length, MaxLines) : lines.Length;
height = lineCount * fontSize * effectiveLineHeight;
}
}
width += paddingH;
height += paddingV;
// Respect explicit size requests
if (WidthRequest >= 0)
{
width = WidthRequest;
}
if (HeightRequest >= 0)
{
height = HeightRequest;
}
return new Size(Math.Max(width, 1.0), Math.Max(height, 1.0));
}
#endregion
}