// 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 Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform.Linux.Rendering; using Microsoft.Maui.Platform.Linux.Services; using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// Skia-rendered multiline text editor control with full XAML styling support. /// Implements IInputContext for IME (Input Method Editor) support. /// public class SkiaEditor : SkiaView, IInputContext { #region BindableProperties /// /// Bindable property for Text. /// public static readonly BindableProperty TextProperty = BindableProperty.Create( nameof(Text), typeof(string), typeof(SkiaEditor), "", BindingMode.OneWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).OnTextPropertyChanged((string)o, (string)n)); /// /// Bindable property for Placeholder. /// public static readonly BindableProperty PlaceholderProperty = BindableProperty.Create( nameof(Placeholder), typeof(string), typeof(SkiaEditor), "", BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for TextColor. /// Default is null to match MAUI Editor.TextColor (falls back to platform default). /// public static readonly BindableProperty TextColorProperty = BindableProperty.Create( nameof(TextColor), typeof(Color), typeof(SkiaEditor), null, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for PlaceholderColor. /// Default is null to match MAUI Editor.PlaceholderColor (falls back to platform default). /// public static readonly BindableProperty PlaceholderColorProperty = BindableProperty.Create( nameof(PlaceholderColor), typeof(Color), typeof(SkiaEditor), null, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for BorderColor. /// public static readonly BindableProperty BorderColorProperty = BindableProperty.Create( nameof(BorderColor), typeof(Color), typeof(SkiaEditor), Color.FromRgb(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for SelectionColor. /// public static readonly BindableProperty SelectionColorProperty = BindableProperty.Create( nameof(SelectionColor), typeof(Color), typeof(SkiaEditor), Color.FromRgba(0x21, 0x96, 0xF3, 0x60), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for CursorColor. /// public static readonly BindableProperty CursorColorProperty = BindableProperty.Create( nameof(CursorColor), typeof(Color), typeof(SkiaEditor), Color.FromRgb(0x21, 0x96, 0xF3), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for FontFamily. /// Default is empty string to match MAUI Editor.FontFamily (falls back to platform default). /// public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create( nameof(FontFamily), typeof(string), typeof(SkiaEditor), string.Empty, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for FontSize. /// public static readonly BindableProperty FontSizeProperty = BindableProperty.Create( nameof(FontSize), typeof(double), typeof(SkiaEditor), 14.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for LineHeight. /// public static readonly BindableProperty LineHeightProperty = BindableProperty.Create( nameof(LineHeight), typeof(double), typeof(SkiaEditor), 1.4, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for CornerRadius. /// public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create( nameof(CornerRadius), typeof(double), typeof(SkiaEditor), 4.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for Padding. /// public static new readonly BindableProperty PaddingProperty = BindableProperty.Create( nameof(Padding), typeof(Thickness), typeof(SkiaEditor), new Thickness(12), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for IsReadOnly. /// public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create( nameof(IsReadOnly), typeof(bool), typeof(SkiaEditor), false, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for MaxLength. /// public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create( nameof(MaxLength), typeof(int), typeof(SkiaEditor), -1, BindingMode.TwoWay); /// /// Bindable property for AutoSize. /// public static readonly BindableProperty AutoSizeProperty = BindableProperty.Create( nameof(AutoSize), typeof(bool), typeof(SkiaEditor), false, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for FontAttributes. /// public static readonly BindableProperty FontAttributesProperty = BindableProperty.Create( nameof(FontAttributes), typeof(FontAttributes), typeof(SkiaEditor), FontAttributes.None, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); /// /// Bindable property for CharacterSpacing. /// public static readonly BindableProperty CharacterSpacingProperty = BindableProperty.Create( nameof(CharacterSpacing), typeof(double), typeof(SkiaEditor), 0.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for IsTextPredictionEnabled. /// public static readonly BindableProperty IsTextPredictionEnabledProperty = BindableProperty.Create( nameof(IsTextPredictionEnabled), typeof(bool), typeof(SkiaEditor), true); /// /// Bindable property for IsSpellCheckEnabled. /// public static readonly BindableProperty IsSpellCheckEnabledProperty = BindableProperty.Create( nameof(IsSpellCheckEnabled), typeof(bool), typeof(SkiaEditor), true); /// /// Bindable property for SelectionLength. /// public static readonly BindableProperty SelectionLengthProperty = BindableProperty.Create( nameof(SelectionLength), typeof(int), typeof(SkiaEditor), 0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for CursorPosition. /// public static readonly BindableProperty CursorPositionProperty = BindableProperty.Create( nameof(CursorPosition), typeof(int), typeof(SkiaEditor), 0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).OnCursorPositionPropertyChanged((int)n)); /// /// Bindable property for HorizontalTextAlignment. /// public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create( nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(SkiaEditor), TextAlignment.Start, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for VerticalTextAlignment. /// public static readonly BindableProperty VerticalTextAlignmentProperty = BindableProperty.Create( nameof(VerticalTextAlignment), typeof(TextAlignment), typeof(SkiaEditor), TextAlignment.Start, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for background color exposed for MAUI binding. /// public static readonly BindableProperty EditorBackgroundColorProperty = BindableProperty.Create( nameof(EditorBackgroundColor), typeof(Color), typeof(SkiaEditor), Colors.White, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); #endregion #region Color Conversion Helper /// /// Converts a MAUI Color to SkiaSharp SKColor. /// private static SKColor ToSKColor(Color? color) { if (color == null) return SKColors.Transparent; return new SKColor( (byte)(color.Red * 255), (byte)(color.Green * 255), (byte)(color.Blue * 255), (byte)(color.Alpha * 255)); } /// /// Gets the effective text color (platform default black if null). /// private SKColor GetEffectiveTextColor() { return TextColor != null ? ToSKColor(TextColor) : SKColors.Black; } /// /// Gets the effective placeholder color (platform default gray if null). /// private SKColor GetEffectivePlaceholderColor() { return PlaceholderColor != null ? ToSKColor(PlaceholderColor) : new SKColor(0x80, 0x80, 0x80); } /// /// Gets the effective font family (platform default "Sans" if empty). /// private string GetEffectiveFontFamily() { return string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily; } /// /// Determines if text should be rendered right-to-left based on FlowDirection. /// private bool IsRightToLeft() { return FlowDirection == FlowDirection.RightToLeft; } /// /// Gets the horizontal alignment accounting for FlowDirection. /// private float GetEffectiveTextX(SKRect contentBounds, float textWidth) { bool isRtl = IsRightToLeft(); return HorizontalTextAlignment switch { TextAlignment.Start => isRtl ? contentBounds.Right - textWidth : contentBounds.Left, TextAlignment.Center => contentBounds.MidX - textWidth / 2, TextAlignment.End => isRtl ? contentBounds.Left : contentBounds.Right - textWidth, _ => isRtl ? contentBounds.Right - textWidth : contentBounds.Left }; } #endregion #region Properties /// /// Gets or sets the text content. /// public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } /// /// Gets or sets the placeholder text. /// public string Placeholder { get => (string)GetValue(PlaceholderProperty); set => SetValue(PlaceholderProperty, value); } /// /// Gets or sets the text color. Null means platform default (black). /// public Color? TextColor { get => (Color?)GetValue(TextColorProperty); set => SetValue(TextColorProperty, value); } /// /// Gets or sets the placeholder color. Null means platform default (gray). /// public Color? PlaceholderColor { get => (Color?)GetValue(PlaceholderColorProperty); set => SetValue(PlaceholderColorProperty, value); } /// /// Gets or sets the border color. /// public Color BorderColor { get => (Color)GetValue(BorderColorProperty); set => SetValue(BorderColorProperty, value); } /// /// Gets or sets the selection color. /// public Color SelectionColor { get => (Color)GetValue(SelectionColorProperty); set => SetValue(SelectionColorProperty, value); } /// /// Gets or sets the cursor color. /// public Color CursorColor { get => (Color)GetValue(CursorColorProperty); set => SetValue(CursorColorProperty, 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 line height multiplier. /// public double LineHeight { get => (double)GetValue(LineHeightProperty); set => SetValue(LineHeightProperty, value); } /// /// Gets or sets the corner radius. /// public double CornerRadius { get => (double)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } /// /// Gets or sets the padding. /// public new Thickness Padding { get => (Thickness)GetValue(PaddingProperty); set => SetValue(PaddingProperty, value); } /// /// Gets or sets whether the editor is read-only. /// public bool IsReadOnly { get => (bool)GetValue(IsReadOnlyProperty); set => SetValue(IsReadOnlyProperty, value); } /// /// Gets or sets the maximum length. -1 for unlimited. /// public int MaxLength { get => (int)GetValue(MaxLengthProperty); set => SetValue(MaxLengthProperty, value); } /// /// Gets or sets whether the editor auto-sizes to content. /// public bool AutoSize { get => (bool)GetValue(AutoSizeProperty); set => SetValue(AutoSizeProperty, value); } /// /// Gets or sets the font attributes (Bold, Italic, etc.). /// public FontAttributes FontAttributes { get => (FontAttributes)GetValue(FontAttributesProperty); set => SetValue(FontAttributesProperty, value); } /// /// Gets or sets the character spacing. /// public double CharacterSpacing { get => (double)GetValue(CharacterSpacingProperty); set => SetValue(CharacterSpacingProperty, value); } /// /// Gets or sets whether text prediction is enabled. /// public bool IsTextPredictionEnabled { get => (bool)GetValue(IsTextPredictionEnabledProperty); set => SetValue(IsTextPredictionEnabledProperty, value); } /// /// Gets or sets whether spell check is enabled. /// public bool IsSpellCheckEnabled { get => (bool)GetValue(IsSpellCheckEnabledProperty); set => SetValue(IsSpellCheckEnabledProperty, value); } /// /// Gets or sets the cursor position. /// public int CursorPosition { get => _cursorPosition; set { var newValue = Math.Clamp(value, 0, (Text ?? "").Length); if (_cursorPosition != newValue) { _cursorPosition = newValue; SetValue(CursorPositionProperty, newValue); EnsureCursorVisible(); Invalidate(); } } } /// /// Gets or sets the selection length. /// public int SelectionLength { get => _selectionLength; set { if (_selectionLength != value) { _selectionLength = value; SetValue(SelectionLengthProperty, value); Invalidate(); } } } /// /// 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 editor background color (MAUI-exposed property). /// public Color EditorBackgroundColor { get => (Color)GetValue(EditorBackgroundColorProperty); set => SetValue(EditorBackgroundColorProperty, value); } #endregion private void OnCursorPositionPropertyChanged(int newValue) { var clampedValue = Math.Clamp(newValue, 0, (Text ?? "").Length); if (_cursorPosition != clampedValue) { _cursorPosition = clampedValue; EnsureCursorVisible(); Invalidate(); } } private int _cursorPosition; private int _selectionStart = -1; private int _selectionLength; private float _scrollOffsetY; private bool _cursorVisible = true; private DateTime _lastCursorBlink = DateTime.Now; private List _lines = new() { "" }; private float _wrapWidth = 0; // Available width for word wrapping private bool _isSelecting; // For mouse-based text selection private DateTime _lastClickTime = DateTime.MinValue; private float _lastClickX; private float _lastClickY; private const double DoubleClickThresholdMs = 400; // IME (Input Method Editor) support private string _preEditText = string.Empty; private int _preEditCursorPosition; private IInputMethodService? _inputMethodService; #region IInputContext Implementation /// /// Gets or sets the text for IME context. /// string IInputContext.Text { get => Text; set => Text = value; } /// /// Gets or sets the cursor position for IME context. /// int IInputContext.CursorPosition { get => _cursorPosition; set => CursorPosition = value; } /// /// Gets the selection start for IME context. /// int IInputContext.SelectionStart => _selectionStart; /// /// Gets the selection length for IME context. /// int IInputContext.SelectionLength => _selectionLength; /// /// Called when IME commits text. /// public void OnTextCommitted(string text) { if (IsReadOnly) return; // Delete selection if any if (_selectionLength != 0) { DeleteSelection(); } // Clear pre-edit text _preEditText = string.Empty; _preEditCursorPosition = 0; // Check max length if (MaxLength > 0 && Text.Length + text.Length > MaxLength) { text = text.Substring(0, MaxLength - Text.Length); } // Insert committed text at cursor var newText = Text.Insert(_cursorPosition, text); var newPos = _cursorPosition + text.Length; Text = newText; _cursorPosition = newPos; EnsureCursorVisible(); Invalidate(); } /// /// Called when IME pre-edit (composition) text changes. /// public void OnPreEditChanged(string preEditText, int cursorPosition) { _preEditText = preEditText ?? string.Empty; _preEditCursorPosition = cursorPosition; Invalidate(); } /// /// Called when IME pre-edit ends (cancelled or committed). /// public void OnPreEditEnded() { _preEditText = string.Empty; _preEditCursorPosition = 0; Invalidate(); } #endregion /// /// Event raised when text changes. /// public event EventHandler? TextChanged; /// /// Event raised when editing is completed. /// public event EventHandler? Completed; public SkiaEditor() { IsFocusable = true; // Get IME service from factory _inputMethodService = InputMethodServiceFactory.Instance; } private void OnTextPropertyChanged(string oldText, string newText) { var text = newText ?? ""; if (MaxLength > 0 && text.Length > MaxLength) { text = text.Substring(0, MaxLength); SetValue(TextProperty, text); return; } UpdateLines(); _cursorPosition = Math.Min(_cursorPosition, text.Length); _scrollOffsetY = 0; // Reset scroll when text changes externally _selectionLength = 0; TextChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } private void UpdateLines() { _lines.Clear(); var text = Text ?? ""; if (string.IsNullOrEmpty(text)) { _lines.Add(""); return; } using var font = new SKFont(SKTypeface.Default, (float)FontSize); // Split by actual newlines first var paragraphs = text.Split('\n'); foreach (var paragraph in paragraphs) { if (string.IsNullOrEmpty(paragraph)) { _lines.Add(""); continue; } // Word wrap this paragraph if we have a known width if (_wrapWidth > 0) { WrapParagraph(paragraph, font, _wrapWidth); } else { _lines.Add(paragraph); } } if (_lines.Count == 0) { _lines.Add(""); } } private void WrapParagraph(string paragraph, SKFont font, float maxWidth) { var words = paragraph.Split(' '); var currentLine = ""; foreach (var word in words) { var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word; var lineWidth = MeasureText(testLine, font); if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine)) { // Line too long, save current and start new _lines.Add(currentLine); currentLine = word; } else { currentLine = testLine; } } // Add remaining text if (!string.IsNullOrEmpty(currentLine)) { _lines.Add(currentLine); } } private (int line, int column) GetLineColumn(int position) { var pos = 0; for (int i = 0; i < _lines.Count; i++) { var lineLength = _lines[i].Length; if (pos + lineLength >= position || i == _lines.Count - 1) { return (i, position - pos); } pos += lineLength + 1; } return (_lines.Count - 1, _lines[^1].Length); } private int GetPosition(int line, int column) { var pos = 0; for (int i = 0; i < line && i < _lines.Count; i++) { pos += _lines[i].Length + 1; } if (line < _lines.Count) { pos += Math.Min(column, _lines[line].Length); } return Math.Min(pos, Text.Length); } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { var paddingLeft = (float)Padding.Left; var paddingTop = (float)Padding.Top; var paddingRight = (float)Padding.Right; var paddingBottom = (float)Padding.Bottom; var fontSize = (float)FontSize; var lineHeight = (float)LineHeight; var cornerRadius = (float)CornerRadius; // Update wrap width if bounds changed and re-wrap text var newWrapWidth = bounds.Width - paddingLeft - paddingRight; if (Math.Abs(newWrapWidth - _wrapWidth) > 1) { _wrapWidth = newWrapWidth; UpdateLines(); } // Handle cursor blinking if (IsFocused && (DateTime.Now - _lastCursorBlink).TotalMilliseconds > 500) { _cursorVisible = !_cursorVisible; _lastCursorBlink = DateTime.Now; } // Draw background var bgColor = EditorBackgroundColor != null ? ToSKColor(EditorBackgroundColor) : (IsEnabled ? SKColors.White : new SKColor(0xF5, 0xF5, 0xF5)); using var bgPaint = new SKPaint { Color = bgColor, Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), bgPaint); // Draw border using var borderPaint = new SKPaint { Color = IsFocused ? ToSKColor(CursorColor) : ToSKColor(BorderColor), Style = SKPaintStyle.Stroke, StrokeWidth = IsFocused ? 2 : 1, IsAntialias = true }; canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), borderPaint); // Setup text rendering using var font = new SKFont(SKTypeface.Default, fontSize); var lineSpacing = fontSize * lineHeight; // Clip to content area var contentRect = new SKRect( bounds.Left + paddingLeft, bounds.Top + paddingTop, bounds.Right - paddingRight, bounds.Bottom - paddingBottom); canvas.Save(); canvas.ClipRect(contentRect); // Don't translate - let the text draw at absolute positions // canvas.Translate(0, -_scrollOffsetY); if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder)) { using var placeholderPaint = new SKPaint(font) { Color = GetEffectivePlaceholderColor(), IsAntialias = true }; canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + fontSize, placeholderPaint); } else { var textColor = GetEffectiveTextColor(); using var textPaint = new SKPaint(font) { Color = IsEnabled ? textColor : textColor.WithAlpha(128), IsAntialias = true }; using var selectionPaint = new SKPaint { Color = ToSKColor(SelectionColor), Style = SKPaintStyle.Fill }; var y = contentRect.Top + fontSize; var charIndex = 0; for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++) { var line = _lines[lineIndex]; var x = contentRect.Left; // Draw selection for this line if applicable if (_selectionStart >= 0 && _selectionLength != 0) { // Handle both positive and negative selection lengths var selStart = _selectionLength > 0 ? _selectionStart : _selectionStart + _selectionLength; var selEnd = _selectionLength > 0 ? _selectionStart + _selectionLength : _selectionStart; var lineStart = charIndex; var lineEnd = charIndex + line.Length; if (selEnd > lineStart && selStart < lineEnd) { var selStartInLine = Math.Max(0, selStart - lineStart); var selEndInLine = Math.Min(line.Length, selEnd - lineStart); var startX = x + MeasureText(line.Substring(0, selStartInLine), font); var endX = x + MeasureText(line.Substring(0, selEndInLine), font); canvas.DrawRect(new SKRect(startX, y - fontSize, endX, y + lineSpacing - fontSize), selectionPaint); } } // 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) { if (cursorLine == lineIndex) { // 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), Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true }; canvas.DrawLine(cursorX, y - fontSize + 2, cursorX, y + 2, cursorPaint); } } y += lineSpacing; charIndex += line.Length + 1; } } canvas.Restore(); // Draw scrollbar if needed var totalHeight = _lines.Count * fontSize * lineHeight; if (totalHeight > contentRect.Height) { DrawScrollbar(canvas, bounds, contentRect.Height, totalHeight); } } private float MeasureText(string text, SKFont font) { if (string.IsNullOrEmpty(text)) return 0; using var paint = new SKPaint(font); return paint.MeasureText(text); } private void DrawScrollbar(SKCanvas canvas, SKRect bounds, float viewHeight, float contentHeight) { var scrollbarWidth = 6f; var scrollbarMargin = 2f; var paddingTop = (float)Padding.Top; var scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight)); var scrollbarY = bounds.Top + paddingTop + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight); using var paint = new SKPaint { Color = new SKColor(0, 0, 0, 60), Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRoundRect(new SKRoundRect( new SKRect( bounds.Right - scrollbarWidth - scrollbarMargin, scrollbarY, bounds.Right - scrollbarMargin, scrollbarY + scrollbarHeight), scrollbarWidth / 2), paint); } private void EnsureCursorVisible() { var (line, col) = GetLineColumn(_cursorPosition); var fontSize = (float)FontSize; var lineHeight = (float)LineHeight; var lineSpacing = fontSize * lineHeight; var cursorY = line * lineSpacing; var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom); if (cursorY < _scrollOffsetY) { _scrollOffsetY = cursorY; } else if (cursorY + lineSpacing > _scrollOffsetY + viewHeight) { _scrollOffsetY = cursorY + lineSpacing - viewHeight; } } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; IsFocused = true; // Use screen coordinates for proper hit detection var screenBounds = ScreenBounds; var paddingLeft = (float)Padding.Left; var paddingTop = (float)Padding.Top; var contentX = e.X - screenBounds.Left - paddingLeft; var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY; var fontSize = (float)FontSize; var lineSpacing = fontSize * (float)LineHeight; var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1); using var font = new SKFont(SKTypeface.Default, fontSize); var line = _lines[clickedLine]; var clickedCol = 0; for (int i = 0; i <= line.Length; i++) { var charX = MeasureText(line.Substring(0, i), font); if (charX > contentX) { clickedCol = i > 0 ? i - 1 : 0; break; } clickedCol = i; } _cursorPosition = GetPosition(clickedLine, clickedCol); // Check for double-click (select word) var now = DateTime.UtcNow; var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds; var distanceFromLastClick = Math.Sqrt(Math.Pow(e.X - _lastClickX, 2) + Math.Pow(e.Y - _lastClickY, 2)); if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10) { // Double-click: select the word at cursor SelectWordAtCursor(); _lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues _isSelecting = false; } else { // Single click: start selection _selectionStart = _cursorPosition; _selectionLength = 0; _isSelecting = true; _lastClickTime = now; _lastClickX = e.X; _lastClickY = e.Y; } _cursorVisible = true; _lastCursorBlink = DateTime.Now; Invalidate(); } public override void OnPointerMoved(PointerEventArgs e) { if (!IsEnabled || !_isSelecting) return; // Calculate position from mouse coordinates var screenBounds = ScreenBounds; var paddingLeft = (float)Padding.Left; var paddingTop = (float)Padding.Top; var contentX = e.X - screenBounds.Left - paddingLeft; var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY; var fontSize = (float)FontSize; var lineSpacing = fontSize * (float)LineHeight; var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1); using var font = new SKFont(SKTypeface.Default, fontSize); var line = _lines[clickedLine]; var clickedCol = 0; for (int i = 0; i <= line.Length; i++) { var charX = MeasureText(line.Substring(0, i), font); if (charX > contentX) { clickedCol = i > 0 ? i - 1 : 0; break; } clickedCol = i; } var newPosition = GetPosition(clickedLine, clickedCol); if (newPosition != _cursorPosition) { _cursorPosition = newPosition; _selectionLength = _cursorPosition - _selectionStart; _cursorVisible = true; _lastCursorBlink = DateTime.Now; Invalidate(); } } public override void OnPointerReleased(PointerEventArgs e) { _isSelecting = false; } public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; var (line, col) = GetLineColumn(_cursorPosition); _cursorVisible = true; _lastCursorBlink = DateTime.Now; switch (e.Key) { case Key.Left: if (_cursorPosition > 0) { _cursorPosition--; EnsureCursorVisible(); } e.Handled = true; break; case Key.Right: if (_cursorPosition < Text.Length) { _cursorPosition++; EnsureCursorVisible(); } e.Handled = true; break; case Key.Up: if (line > 0) { _cursorPosition = GetPosition(line - 1, col); EnsureCursorVisible(); } e.Handled = true; break; case Key.Down: if (line < _lines.Count - 1) { _cursorPosition = GetPosition(line + 1, col); EnsureCursorVisible(); } e.Handled = true; break; case Key.Home: _cursorPosition = GetPosition(line, 0); EnsureCursorVisible(); e.Handled = true; break; case Key.End: _cursorPosition = GetPosition(line, _lines[line].Length); EnsureCursorVisible(); e.Handled = true; break; case Key.Enter: if (!IsReadOnly) { InsertText("\n"); } e.Handled = true; break; case Key.Backspace: if (!IsReadOnly) { if (_selectionLength != 0) { DeleteSelection(); } else if (_cursorPosition > 0) { Text = Text.Remove(_cursorPosition - 1, 1); _cursorPosition--; } EnsureCursorVisible(); } e.Handled = true; break; case Key.Delete: if (!IsReadOnly) { if (_selectionLength != 0) { DeleteSelection(); } else if (_cursorPosition < Text.Length) { Text = Text.Remove(_cursorPosition, 1); } } e.Handled = true; break; case Key.Tab: if (!IsReadOnly) { InsertText(" "); } e.Handled = true; break; case Key.A: if (e.Modifiers.HasFlag(KeyModifiers.Control)) { SelectAll(); e.Handled = true; } break; case Key.C: if (e.Modifiers.HasFlag(KeyModifiers.Control)) { CopyToClipboard(); e.Handled = true; } break; case Key.V: if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly) { PasteFromClipboard(); e.Handled = true; } break; case Key.X: if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly) { CutToClipboard(); e.Handled = true; } break; } Invalidate(); } public override void OnTextInput(TextInputEventArgs e) { if (!IsEnabled || IsReadOnly) return; // Ignore control characters (Ctrl+key combinations send ASCII control codes) if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32) return; if (!string.IsNullOrEmpty(e.Text)) { InsertText(e.Text); e.Handled = true; } } private void InsertText(string text) { if (_selectionLength > 0) { var currentText = Text; Text = currentText.Remove(_selectionStart, _selectionLength); _cursorPosition = _selectionStart; _selectionStart = -1; _selectionLength = 0; } if (MaxLength > 0 && Text.Length + text.Length > MaxLength) { text = text.Substring(0, Math.Max(0, MaxLength - Text.Length)); } if (!string.IsNullOrEmpty(text)) { Text = Text.Insert(_cursorPosition, text); _cursorPosition += text.Length; EnsureCursorVisible(); } } public override void OnScroll(ScrollEventArgs e) { var fontSize = (float)FontSize; var lineHeight = (float)LineHeight; var lineSpacing = fontSize * lineHeight; var totalHeight = _lines.Count * lineSpacing; var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom); var maxScroll = Math.Max(0, totalHeight - viewHeight); _scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll); Invalidate(); } public override void OnFocusGained() { 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); } #region Selection and Clipboard public void SelectAll() { _selectionStart = 0; _cursorPosition = Text.Length; _selectionLength = Text.Length; Invalidate(); } private void SelectWordAtCursor() { if (string.IsNullOrEmpty(Text)) return; // Find word boundaries int start = _cursorPosition; int end = _cursorPosition; // 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; _cursorPosition = end; _selectionLength = end - start; } private static bool IsWordChar(char c) { return char.IsLetterOrDigit(c) || c == '_'; } private void CopyToClipboard() { if (_selectionLength == 0) return; var start = Math.Min(_selectionStart, _selectionStart + _selectionLength); var length = Math.Abs(_selectionLength); var selectedText = Text.Substring(start, length); // Use system clipboard via xclip/xsel SystemClipboard.SetText(selectedText); } private void CutToClipboard() { CopyToClipboard(); DeleteSelection(); Invalidate(); } private void PasteFromClipboard() { // Get from system clipboard var text = SystemClipboard.GetText(); if (string.IsNullOrEmpty(text)) return; if (_selectionLength != 0) { DeleteSelection(); } InsertText(text); } private void DeleteSelection() { if (_selectionLength == 0) return; var start = Math.Min(_selectionStart, _selectionStart + _selectionLength); var length = Math.Abs(_selectionLength); Text = Text.Remove(start, length); _cursorPosition = start; _selectionStart = -1; _selectionLength = 0; } #endregion /// /// Draws text with font fallback for emoji, CJK, and other scripts. /// private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface) { if (string.IsNullOrEmpty(text)) { return; } // Use FontFallbackManager for mixed-script text var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface); if (runs.Count <= 1) { // Single run or no fallback needed - draw directly canvas.DrawText(text, x, y, paint); return; } // Multiple runs with different fonts float currentX = x; foreach (var run in runs) { using var runFont = new SKFont(run.Typeface, (float)FontSize); using var runPaint = new SKPaint(runFont) { Color = paint.Color, IsAntialias = true }; canvas.DrawText(run.Text, currentX, y, runPaint); currentX += runPaint.MeasureText(run.Text); } } /// /// Draws underline for IME pre-edit (composition) text. /// private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds) { // Calculate pre-edit text position var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); var preEditStartX = x + paint.MeasureText(textToCursor); var preEditEndX = preEditStartX + paint.MeasureText(_preEditText); // Draw dotted underline to indicate composition using var underlinePaint = new SKPaint { Color = paint.Color, StrokeWidth = 1, IsAntialias = true, PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0) }; var underlineY = y + 2; canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint); } /// /// Updates the IME cursor location for candidate window positioning. /// private void UpdateImeCursorLocation() { if (_inputMethodService == null) return; var screenBounds = ScreenBounds; var (line, col) = GetLineColumn(_cursorPosition); var fontSize = (float)FontSize; var lineSpacing = fontSize * (float)LineHeight; using var font = new SKFont(SKTypeface.Default, fontSize); using var paint = new SKPaint(font); var lineText = line < _lines.Count ? _lines[line] : ""; var textToCursor = lineText.Substring(0, Math.Min(col, lineText.Length)); var cursorX = paint.MeasureText(textToCursor); int x = (int)(screenBounds.Left + Padding.Left + cursorX); int y = (int)(screenBounds.Top + Padding.Top + line * lineSpacing - _scrollOffsetY); int height = (int)fontSize; _inputMethodService.SetCursorLocation(x, y, 2, height); } protected override SKSize MeasureOverride(SKSize availableSize) { if (AutoSize) { var fontSize = (float)FontSize; var lineHeight = (float)LineHeight; var lineSpacing = fontSize * lineHeight; var verticalPadding = (float)(Padding.Top + Padding.Bottom); var height = Math.Max(lineSpacing + verticalPadding, _lines.Count * lineSpacing + verticalPadding); return new SKSize( availableSize.Width < float.MaxValue ? availableSize.Width : 200, (float)Math.Min(height, availableSize.Height < float.MaxValue ? availableSize.Height : 200)); } return new SKSize( availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200, availableSize.Height < float.MaxValue ? Math.Min(availableSize.Height, 150) : 150); } }