// 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; 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 partial 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), Colors.Transparent, 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.Transparent, 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) => TextRenderingHelper.ToSKColor(color, SKColors.Transparent); /// /// Gets the effective text color (platform default black if null). /// private SKColor GetEffectiveTextColor() { return TextColor != null ? ToSKColor(TextColor) : SkiaTheme.TextPrimarySK; } /// /// Gets the effective placeholder color (platform default gray if null). /// private SKColor GetEffectivePlaceholderColor() { return PlaceholderColor != null ? ToSKColor(PlaceholderColor) : SkiaTheme.TextPlaceholderSK; } /// /// Gets the effective font family (platform default "Sans" if empty). /// private string GetEffectiveFontFamily() => TextRenderingHelper.GetEffectiveFontFamily(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; /// /// 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(); } }