// 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; /// /// 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 - (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; } } /// /// 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 SkiaTheme.TextPrimarySK; return color.ToSKColor(); } 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); } /// /// Determines if text should be rendered right-to-left based on FlowDirection. /// private bool IsRightToLeft() { return FlowDirection == FlowDirection.RightToLeft; } /// /// Gets the effective horizontal alignment for the given alignment, /// accounting for FlowDirection (RTL flips Start/End). /// private float GetHorizontalPosition(TextAlignment alignment, float boundsLeft, float boundsRight, float textWidth) { bool isRtl = IsRightToLeft(); return alignment switch { TextAlignment.Start => isRtl ? boundsRight - textWidth : boundsLeft, TextAlignment.Center => (boundsLeft + boundsRight) / 2 - textWidth / 2, TextAlignment.End => isRtl ? boundsLeft : boundsRight - textWidth, _ => isRtl ? boundsRight - textWidth : boundsLeft }; } #endregion #region Drawing 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; } } } /// /// Draws text with font fallback for emoji, CJK, and other scripts. /// private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface) { if (string.IsNullOrEmpty(text)) { return; } // Use FontFallbackManager for mixed-script text var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); if (runs.Count <= 1) { // Single run or no fallback needed - draw directly canvas.DrawText(text, x, y, paint); return; } // Multiple runs with different fonts float fontSize = FontSize > 0 ? (float)FontSize : 14f; float currentX = x; foreach (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); } } /// /// Draws formatted span text with font fallback for emoji, CJK, and other scripts. /// private void DrawFormattedSpanWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface, float fontSize) { if (string.IsNullOrEmpty(text)) { return; } // Use FontFallbackManager for mixed-script text var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); if (runs.Count <= 1) { // Single run or no fallback needed - draw directly canvas.DrawText(text, x, y, paint); return; } // Multiple runs with different fonts float currentX = x; foreach (var run in runs) { using var runFont = new SKFont(run.Typeface, fontSize); using var runPaint = new SKPaint(runFont) { Color = paint.Color, IsAntialias = true }; canvas.DrawText(run.Text, currentX, y, runPaint); currentX += runPaint.MeasureText(run.Text); } } 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 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 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 { 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 += 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 }