// 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 SkiaSharp;
namespace Microsoft.Maui.Platform;
///
/// Skia-rendered label control matching the .NET MAUI Label API.
///
public class SkiaLabel : SkiaView
{
#region BindableProperties
///
/// Bindable property for Text.
///
public static readonly BindableProperty TextProperty = BindableProperty.Create(
nameof(Text),
typeof(string),
typeof(SkiaLabel),
string.Empty,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
///
/// Bindable property for TextColor.
/// Default is null to match MAUI Label.TextColor (falls back to platform default).
///
public static readonly BindableProperty TextColorProperty = BindableProperty.Create(
nameof(TextColor),
typeof(Color),
typeof(SkiaLabel),
null,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for FontFamily.
///
public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create(
nameof(FontFamily),
typeof(string),
typeof(SkiaLabel),
string.Empty,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
///
/// Bindable property for FontSize.
///
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(
nameof(FontSize),
typeof(double),
typeof(SkiaLabel),
14.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
///
/// Bindable property for FontAttributes.
///
public static readonly BindableProperty FontAttributesProperty = BindableProperty.Create(
nameof(FontAttributes),
typeof(FontAttributes),
typeof(SkiaLabel),
FontAttributes.None,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
///
/// Bindable property for FontAutoScalingEnabled.
///
public static readonly BindableProperty FontAutoScalingEnabledProperty = BindableProperty.Create(
nameof(FontAutoScalingEnabled),
typeof(bool),
typeof(SkiaLabel),
true,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
///
/// Bindable property for CharacterSpacing.
///
public static readonly BindableProperty CharacterSpacingProperty = BindableProperty.Create(
nameof(CharacterSpacing),
typeof(double),
typeof(SkiaLabel),
0.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).InvalidateMeasure());
///
/// Bindable property for TextDecorations.
///
public static readonly BindableProperty TextDecorationsProperty = BindableProperty.Create(
nameof(TextDecorations),
typeof(TextDecorations),
typeof(SkiaLabel),
TextDecorations.None,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for HorizontalTextAlignment.
///
public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create(
nameof(HorizontalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Start,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for VerticalTextAlignment.
/// Default is Start to match MAUI Label.VerticalTextAlignment.
///
public static readonly BindableProperty VerticalTextAlignmentProperty = BindableProperty.Create(
nameof(VerticalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Start,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for LineBreakMode.
///
public static readonly BindableProperty LineBreakModeProperty = BindableProperty.Create(
nameof(LineBreakMode),
typeof(LineBreakMode),
typeof(SkiaLabel),
LineBreakMode.TailTruncation,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for MaxLines.
///
public static readonly BindableProperty MaxLinesProperty = BindableProperty.Create(
nameof(MaxLines),
typeof(int),
typeof(SkiaLabel),
0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
///
/// Bindable property for LineHeight.
/// Default is -1 to match MAUI Label.LineHeight (platform default).
///
public static readonly BindableProperty LineHeightProperty = BindableProperty.Create(
nameof(LineHeight),
typeof(double),
typeof(SkiaLabel),
-1.0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
///
/// Bindable property for TextTransform.
///
public static readonly BindableProperty TextTransformProperty = BindableProperty.Create(
nameof(TextTransform),
typeof(TextTransform),
typeof(SkiaLabel),
TextTransform.Default,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
///
/// Bindable property for TextType.
///
public static readonly BindableProperty TextTypeProperty = BindableProperty.Create(
nameof(TextType),
typeof(TextType),
typeof(SkiaLabel),
TextType.Text,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
///
/// Bindable property for Padding.
///
public static new readonly BindableProperty PaddingProperty = BindableProperty.Create(
nameof(Padding),
typeof(Thickness),
typeof(SkiaLabel),
new Thickness(0),
propertyChanged: (b, o, n) => ((SkiaLabel)b).InvalidateMeasure());
///
/// Bindable property for FormattedText.
///
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
///
/// Gets or sets the text content.
///
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
///
/// Gets or sets the text color.
/// Null means use platform default (black on Linux).
///
public Color? TextColor
{
get => (Color?)GetValue(TextColorProperty);
set => SetValue(TextColorProperty, value);
}
///
/// Gets or sets the font family.
///
public string FontFamily
{
get => (string)GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
///
/// Gets or sets the font size.
///
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
///
/// Gets or sets the font attributes.
///
public FontAttributes FontAttributes
{
get => (FontAttributes)GetValue(FontAttributesProperty);
set => SetValue(FontAttributesProperty, value);
}
///
/// Gets or sets whether font auto-scaling is enabled.
///
public bool FontAutoScalingEnabled
{
get => (bool)GetValue(FontAutoScalingEnabledProperty);
set => SetValue(FontAutoScalingEnabledProperty, value);
}
///
/// Gets or sets the character spacing.
///
public double CharacterSpacing
{
get => (double)GetValue(CharacterSpacingProperty);
set => SetValue(CharacterSpacingProperty, value);
}
///
/// Gets or sets the text decorations.
///
public TextDecorations TextDecorations
{
get => (TextDecorations)GetValue(TextDecorationsProperty);
set => SetValue(TextDecorationsProperty, value);
}
///
/// Gets or sets the horizontal text alignment.
///
public TextAlignment HorizontalTextAlignment
{
get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
set => SetValue(HorizontalTextAlignmentProperty, value);
}
///
/// Gets or sets the vertical text alignment.
///
public TextAlignment VerticalTextAlignment
{
get => (TextAlignment)GetValue(VerticalTextAlignmentProperty);
set => SetValue(VerticalTextAlignmentProperty, value);
}
///
/// Gets or sets the line break mode.
///
public LineBreakMode LineBreakMode
{
get => (LineBreakMode)GetValue(LineBreakModeProperty);
set => SetValue(LineBreakModeProperty, value);
}
///
/// Gets or sets the maximum number of lines.
///
public int MaxLines
{
get => (int)GetValue(MaxLinesProperty);
set => SetValue(MaxLinesProperty, value);
}
///
/// Gets or sets the line height multiplier.
///
public double LineHeight
{
get => (double)GetValue(LineHeightProperty);
set => SetValue(LineHeightProperty, value);
}
///
/// Gets or sets the text transform.
///
public TextTransform TextTransform
{
get => (TextTransform)GetValue(TextTransformProperty);
set => SetValue(TextTransformProperty, value);
}
///
/// Gets or sets the text type.
///
public TextType TextType
{
get => (TextType)GetValue(TextTypeProperty);
set => SetValue(TextTypeProperty, value);
}
///
/// Gets or sets the padding.
///
public new Thickness Padding
{
get => (Thickness)GetValue(PaddingProperty);
set => SetValue(PaddingProperty, value);
}
///
/// Gets or sets the formatted text.
///
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;
///
/// Gets or sets whether text selection is enabled.
///
public bool IsTextSelectionEnabled { get; set; } = true;
///
/// Gets the currently selected text.
///
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
///
/// Occurs when the label is tapped.
///
public event EventHandler? Tapped;
///
/// Raises the Tapped event.
///
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 - 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 - 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;
}
}
///
/// Selects all text in the label.
///
public void SelectAll()
{
var text = GetDisplayText();
_selectionStart = 0;
_selectionLength = text.Length;
Invalidate();
}
///
/// Clears the current selection.
///
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)
{
if (color == null) return SKColors.Black;
return new SKColor(
(byte)(color.Red * 255),
(byte)(color.Green * 255),
(byte)(color.Blue * 255),
(byte)(color.Alpha * 255));
}
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()
{
bool isBold = FontAttributes.HasFlag(FontAttributes.Bold);
bool isItalic = FontAttributes.HasFlag(FontAttributes.Italic);
return new SKFontStyle(
isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
}
#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
float x = HorizontalTextAlignment switch
{
TextAlignment.Start => bounds.Left,
TextAlignment.Center => bounds.MidX - textWidth / 2,
TextAlignment.End => bounds.Right - textWidth,
_ => bounds.Left
};
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 = new SKColor(0x21, 0x96, 0xF3, 0x60), // Semi-transparent blue
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));
}
float x = HorizontalTextAlignment switch
{
TextAlignment.Start => bounds.Left,
TextAlignment.Center => bounds.MidX - textWidth / 2,
TextAlignment.End => bounds.Right - textWidth,
_ => bounds.Left
};
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 (CharacterSpacing == 0 || string.IsNullOrEmpty(text) || text.Length <= 1)
{
canvas.DrawText(text, x, y, paint);
return;
}
float currentX = x;
foreach (char c in text)
{
string charStr = c.ToString();
canvas.DrawText(charStr, currentX, y, paint);
currentX += paint.MeasureText(charStr) + (float)CharacterSpacing;
}
}
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;
}
canvas.DrawText(span.Text, x, y, paint);
// 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 WrapText(string text, SKPaint paint, float maxWidth)
{
var lines = new List();
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;
}
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 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 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 SKSize MeasureOverride(SKSize availableSize)
{
var padding = Padding;
float paddingH = (float)(padding.Left + padding.Right);
float paddingV = (float)(padding.Top + padding.Bottom);
string displayText = GetDisplayText();
if (string.IsNullOrEmpty(displayText) && (FormattedText == null || FormattedText.Spans.Count == 0))
{
return new SKSize(paddingH, paddingV + (float)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);
float 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 = (float)(fontSize * effectiveLineHeight);
foreach (var span in FormattedText.Spans)
{
if (!string.IsNullOrEmpty(span.Text))
{
width += paint.MeasureText(span.Text);
}
}
}
else
{
var textBounds = new SKRect();
paint.MeasureText(displayText, ref textBounds);
width = textBounds.Width;
height = textBounds.Height;
// Account for character spacing
if (CharacterSpacing != 0 && displayText.Length > 1)
{
width += (float)(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 = (float)(lineCount * fontSize * effectiveLineHeight);
}
}
width += paddingH;
height += paddingV;
// Respect explicit size requests
if (WidthRequest >= 0)
{
width = (float)WidthRequest;
}
if (HeightRequest >= 0)
{
height = (float)HeightRequest;
}
return new SKSize(Math.Max(width, 1f), Math.Max(height, 1f));
}
#endregion
}