// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using SkiaSharp; using Microsoft.Maui.Platform.Linux.Rendering; namespace Microsoft.Maui.Platform; /// /// Skia-rendered text entry control with full XAML styling and data binding support. /// public class SkiaEntry : SkiaView { #region BindableProperties /// /// Bindable property for Text. /// public static readonly BindableProperty TextProperty = BindableProperty.Create( nameof(Text), typeof(string), typeof(SkiaEntry), "", BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEntry)b).OnTextPropertyChanged((string)o, (string)n)); /// /// Bindable property for Placeholder. /// public static readonly BindableProperty PlaceholderProperty = BindableProperty.Create( nameof(Placeholder), typeof(string), typeof(SkiaEntry), "", propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for PlaceholderColor. /// public static readonly BindableProperty PlaceholderColorProperty = BindableProperty.Create( nameof(PlaceholderColor), typeof(SKColor), typeof(SkiaEntry), new SKColor(0x9E, 0x9E, 0x9E), propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for TextColor. /// public static readonly BindableProperty TextColorProperty = BindableProperty.Create( nameof(TextColor), typeof(SKColor), typeof(SkiaEntry), SKColors.Black, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for EntryBackgroundColor. /// public static readonly BindableProperty EntryBackgroundColorProperty = BindableProperty.Create( nameof(EntryBackgroundColor), typeof(SKColor), typeof(SkiaEntry), SKColors.White, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for BorderColor. /// public static readonly BindableProperty BorderColorProperty = BindableProperty.Create( nameof(BorderColor), typeof(SKColor), typeof(SkiaEntry), new SKColor(0xBD, 0xBD, 0xBD), propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for FocusedBorderColor. /// public static readonly BindableProperty FocusedBorderColorProperty = BindableProperty.Create( nameof(FocusedBorderColor), typeof(SKColor), typeof(SkiaEntry), new SKColor(0x21, 0x96, 0xF3), propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for SelectionColor. /// public static readonly BindableProperty SelectionColorProperty = BindableProperty.Create( nameof(SelectionColor), typeof(SKColor), typeof(SkiaEntry), new SKColor(0x21, 0x96, 0xF3, 0x80), propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for CursorColor. /// public static readonly BindableProperty CursorColorProperty = BindableProperty.Create( nameof(CursorColor), typeof(SKColor), typeof(SkiaEntry), new SKColor(0x21, 0x96, 0xF3), propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for FontFamily. /// public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create( nameof(FontFamily), typeof(string), typeof(SkiaEntry), "Sans", propertyChanged: (b, o, n) => ((SkiaEntry)b).InvalidateMeasure()); /// /// Bindable property for FontSize. /// public static readonly BindableProperty FontSizeProperty = BindableProperty.Create( nameof(FontSize), typeof(float), typeof(SkiaEntry), 14f, propertyChanged: (b, o, n) => ((SkiaEntry)b).InvalidateMeasure()); /// /// Bindable property for IsBold. /// public static readonly BindableProperty IsBoldProperty = BindableProperty.Create( nameof(IsBold), typeof(bool), typeof(SkiaEntry), false, propertyChanged: (b, o, n) => ((SkiaEntry)b).InvalidateMeasure()); /// /// Bindable property for IsItalic. /// public static readonly BindableProperty IsItalicProperty = BindableProperty.Create( nameof(IsItalic), typeof(bool), typeof(SkiaEntry), false, propertyChanged: (b, o, n) => ((SkiaEntry)b).InvalidateMeasure()); /// /// Bindable property for CornerRadius. /// public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create( nameof(CornerRadius), typeof(float), typeof(SkiaEntry), 4f, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for BorderWidth. /// public static readonly BindableProperty BorderWidthProperty = BindableProperty.Create( nameof(BorderWidth), typeof(float), typeof(SkiaEntry), 1f, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for Padding. /// public static readonly BindableProperty PaddingProperty = BindableProperty.Create( nameof(Padding), typeof(SKRect), typeof(SkiaEntry), new SKRect(12, 8, 12, 8), propertyChanged: (b, o, n) => ((SkiaEntry)b).InvalidateMeasure()); /// /// Bindable property for IsPassword. /// public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create( nameof(IsPassword), typeof(bool), typeof(SkiaEntry), false, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for PasswordChar. /// public static readonly BindableProperty PasswordCharProperty = BindableProperty.Create( nameof(PasswordChar), typeof(char), typeof(SkiaEntry), '*', // Use asterisk for universal font compatibility propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for MaxLength. /// public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create( nameof(MaxLength), typeof(int), typeof(SkiaEntry), 0); /// /// Bindable property for IsReadOnly. /// public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create( nameof(IsReadOnly), typeof(bool), typeof(SkiaEntry), false, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for HorizontalTextAlignment. /// public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create( nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(SkiaEntry), TextAlignment.Start, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for VerticalTextAlignment. /// public static readonly BindableProperty VerticalTextAlignmentProperty = BindableProperty.Create( nameof(VerticalTextAlignment), typeof(TextAlignment), typeof(SkiaEntry), TextAlignment.Center, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for ShowClearButton. /// public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create( nameof(ShowClearButton), typeof(bool), typeof(SkiaEntry), false, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); /// /// Bindable property for CharacterSpacing. /// public static readonly BindableProperty CharacterSpacingProperty = BindableProperty.Create( nameof(CharacterSpacing), typeof(float), typeof(SkiaEntry), 0f, propertyChanged: (b, o, n) => ((SkiaEntry)b).Invalidate()); #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 placeholder color. /// public SKColor PlaceholderColor { get => (SKColor)GetValue(PlaceholderColorProperty); set => SetValue(PlaceholderColorProperty, value); } /// /// Gets or sets the text color. /// public SKColor TextColor { get => (SKColor)GetValue(TextColorProperty); set => SetValue(TextColorProperty, value); } /// /// Gets or sets the entry background color. /// public SKColor EntryBackgroundColor { get => (SKColor)GetValue(EntryBackgroundColorProperty); set => SetValue(EntryBackgroundColorProperty, value); } /// /// Gets or sets the border color. /// public SKColor BorderColor { get => (SKColor)GetValue(BorderColorProperty); set => SetValue(BorderColorProperty, value); } /// /// Gets or sets the focused border color. /// public SKColor FocusedBorderColor { get => (SKColor)GetValue(FocusedBorderColorProperty); set => SetValue(FocusedBorderColorProperty, value); } /// /// Gets or sets the selection color. /// public SKColor SelectionColor { get => (SKColor)GetValue(SelectionColorProperty); set => SetValue(SelectionColorProperty, value); } /// /// Gets or sets the cursor color. /// public SKColor CursorColor { get => (SKColor)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 float FontSize { get => (float)GetValue(FontSizeProperty); set => SetValue(FontSizeProperty, value); } /// /// Gets or sets whether the text is bold. /// public bool IsBold { get => (bool)GetValue(IsBoldProperty); set => SetValue(IsBoldProperty, value); } /// /// Gets or sets whether the text is italic. /// public bool IsItalic { get => (bool)GetValue(IsItalicProperty); set => SetValue(IsItalicProperty, value); } /// /// Gets or sets the corner radius. /// public float CornerRadius { get => (float)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } /// /// Gets or sets the border width. /// public float BorderWidth { get => (float)GetValue(BorderWidthProperty); set => SetValue(BorderWidthProperty, value); } /// /// Gets or sets the padding. /// public SKRect Padding { get => (SKRect)GetValue(PaddingProperty); set => SetValue(PaddingProperty, value); } /// /// Gets or sets whether this is a password field. /// public bool IsPassword { get => (bool)GetValue(IsPasswordProperty); set => SetValue(IsPasswordProperty, value); } /// /// Gets or sets the password masking character. /// public char PasswordChar { get => (char)GetValue(PasswordCharProperty); set => SetValue(PasswordCharProperty, value); } /// /// Gets or sets the maximum text length. 0 = unlimited. /// public int MaxLength { get => (int)GetValue(MaxLengthProperty); set => SetValue(MaxLengthProperty, value); } /// /// Gets or sets whether the entry is read-only. /// public bool IsReadOnly { get => (bool)GetValue(IsReadOnlyProperty); set => SetValue(IsReadOnlyProperty, 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 whether to show the clear button. /// public bool ShowClearButton { get => (bool)GetValue(ShowClearButtonProperty); set => SetValue(ShowClearButtonProperty, value); } /// /// Gets or sets the character spacing. /// public float CharacterSpacing { get => (float)GetValue(CharacterSpacingProperty); set => SetValue(CharacterSpacingProperty, value); } /// /// Gets or sets the cursor position. /// public int CursorPosition { get => _cursorPosition; set { _cursorPosition = Math.Clamp(value, 0, Text.Length); ResetCursorBlink(); Invalidate(); } } /// /// Gets or sets the selection length. /// public int SelectionLength { get => _selectionLength; set { _selectionLength = value; Invalidate(); } } #endregion private int _cursorPosition; private int _selectionStart; private int _selectionLength; private float _scrollOffset; private DateTime _cursorBlinkTime = DateTime.UtcNow; private bool _cursorVisible = true; private bool _isSelecting; // For mouse-based text selection private DateTime _lastClickTime = DateTime.MinValue; private float _lastClickX; private const double DoubleClickThresholdMs = 400; /// /// Event raised when text changes. /// public event EventHandler? TextChanged; /// /// Event raised when Enter is pressed. /// public event EventHandler? Completed; public SkiaEntry() { IsFocusable = true; } private void OnTextPropertyChanged(string oldText, string newText) { _cursorPosition = Math.Min(_cursorPosition, (newText ?? "").Length); _scrollOffset = 0; // Reset scroll when text changes externally _selectionLength = 0; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, newText ?? "")); Invalidate(); } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Draw background using var bgPaint = new SKPaint { Color = EntryBackgroundColor, IsAntialias = true, Style = SKPaintStyle.Fill }; var rect = new SKRoundRect(bounds, CornerRadius); canvas.DrawRoundRect(rect, bgPaint); // Draw border var borderColor = IsFocused ? FocusedBorderColor : BorderColor; var borderWidth = IsFocused ? BorderWidth + 1 : BorderWidth; using var borderPaint = new SKPaint { Color = borderColor, IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = borderWidth }; canvas.DrawRoundRect(rect, borderPaint); // Calculate content bounds var contentBounds = new SKRect( bounds.Left + Padding.Left, bounds.Top + Padding.Top, bounds.Right - Padding.Right, bounds.Bottom - Padding.Bottom); // Reserve space for clear button if shown var clearButtonSize = 20f; var clearButtonMargin = 8f; if (ShowClearButton && !string.IsNullOrEmpty(Text) && IsFocused) { contentBounds.Right -= clearButtonSize + clearButtonMargin; } // Set up clipping for text area canvas.Save(); canvas.ClipRect(contentBounds); var fontStyle = GetFontStyle(); var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) ?? SKTypeface.Default; using var font = new SKFont(typeface, FontSize); using var paint = new SKPaint(font) { IsAntialias = true }; var displayText = GetDisplayText(); var hasText = !string.IsNullOrEmpty(displayText); if (hasText) { paint.Color = TextColor; // Measure text to cursor position for scrolling var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); var cursorX = paint.MeasureText(textToCursor); // Auto-scroll to keep cursor visible if (cursorX - _scrollOffset > contentBounds.Width - 10) { _scrollOffset = cursorX - contentBounds.Width + 10; } else if (cursorX - _scrollOffset < 0) { _scrollOffset = cursorX; } // Draw selection (check != 0 to handle both forward and backward selection) if (IsFocused && _selectionLength != 0) { DrawSelection(canvas, paint, displayText, contentBounds); } // Calculate text position based on vertical alignment var textBounds = new SKRect(); paint.MeasureText(displayText, ref textBounds); float x = contentBounds.Left - _scrollOffset; float y = VerticalTextAlignment switch { TextAlignment.Start => contentBounds.Top - textBounds.Top, TextAlignment.End => contentBounds.Bottom - textBounds.Bottom, _ => contentBounds.MidY - textBounds.MidY // Center }; canvas.DrawText(displayText, x, y, paint); // Draw cursor if (IsFocused && !IsReadOnly && _cursorVisible) { DrawCursor(canvas, paint, displayText, contentBounds); } } else if (!string.IsNullOrEmpty(Placeholder)) { // Draw placeholder paint.Color = PlaceholderColor; var textBounds = new SKRect(); paint.MeasureText(Placeholder, ref textBounds); float x = contentBounds.Left; float y = contentBounds.MidY - textBounds.MidY; canvas.DrawText(Placeholder, x, y, paint); } else if (IsFocused && !IsReadOnly && _cursorVisible) { // Draw cursor even with no text DrawCursor(canvas, paint, "", contentBounds); } canvas.Restore(); // Draw clear button if applicable if (ShowClearButton && !string.IsNullOrEmpty(Text) && IsFocused) { DrawClearButton(canvas, bounds, clearButtonSize, clearButtonMargin); } } private SKFontStyle GetFontStyle() { if (IsBold && IsItalic) return SKFontStyle.BoldItalic; if (IsBold) return SKFontStyle.Bold; if (IsItalic) return SKFontStyle.Italic; return SKFontStyle.Normal; } private void DrawClearButton(SKCanvas canvas, SKRect bounds, float size, float margin) { var centerX = bounds.Right - margin - size / 2; var centerY = bounds.MidY; // Draw circle background using var circlePaint = new SKPaint { Color = new SKColor(0xBD, 0xBD, 0xBD), IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawCircle(centerX, centerY, size / 2 - 2, circlePaint); // Draw X using var xPaint = new SKPaint { Color = SKColors.White, IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = 2, StrokeCap = SKStrokeCap.Round }; var offset = size / 4 - 1; canvas.DrawLine(centerX - offset, centerY - offset, centerX + offset, centerY + offset, xPaint); canvas.DrawLine(centerX - offset, centerY + offset, centerX + offset, centerY - offset, xPaint); } private string GetDisplayText() { if (IsPassword && !string.IsNullOrEmpty(Text)) { return new string(PasswordChar, Text.Length); } return Text; } private void DrawSelection(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds) { var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength); var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength); var textToStart = displayText.Substring(0, selStart); var textToEnd = displayText.Substring(0, selEnd); var startX = bounds.Left - _scrollOffset + paint.MeasureText(textToStart); var endX = bounds.Left - _scrollOffset + paint.MeasureText(textToEnd); using var selPaint = new SKPaint { Color = SelectionColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(startX, bounds.Top, endX - startX, bounds.Height, selPaint); } private void DrawCursor(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds) { var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length)); var cursorX = bounds.Left - _scrollOffset + paint.MeasureText(textToCursor); using var cursorPaint = new SKPaint { Color = CursorColor, StrokeWidth = 2, IsAntialias = true }; canvas.DrawLine(cursorX, bounds.Top + 2, cursorX, bounds.Bottom - 2, cursorPaint); } private void ResetCursorBlink() { _cursorBlinkTime = DateTime.UtcNow; _cursorVisible = true; } /// /// Updates cursor blink animation. /// public void UpdateCursorBlink() { if (!IsFocused) return; var elapsed = (DateTime.UtcNow - _cursorBlinkTime).TotalMilliseconds; var newVisible = ((int)(elapsed / 500) % 2) == 0; if (newVisible != _cursorVisible) { _cursorVisible = newVisible; 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; // Delete selection if any if (_selectionLength != 0) { DeleteSelection(); } // Check max length if (MaxLength > 0 && Text.Length >= MaxLength) return; // Insert text at cursor var insertText = e.Text; if (MaxLength > 0) { var remaining = MaxLength - Text.Length; insertText = insertText.Substring(0, Math.Min(insertText.Length, remaining)); } var newText = Text.Insert(_cursorPosition, insertText); var oldPos = _cursorPosition; Text = newText; _cursorPosition = oldPos + insertText.Length; ResetCursorBlink(); Invalidate(); } public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; switch (e.Key) { case Key.Backspace: if (!IsReadOnly) { if (_selectionLength > 0) { DeleteSelection(); } else if (_cursorPosition > 0) { var newText = Text.Remove(_cursorPosition - 1, 1); var newPos = _cursorPosition - 1; Text = newText; _cursorPosition = newPos; } ResetCursorBlink(); Invalidate(); } e.Handled = true; break; case Key.Delete: if (!IsReadOnly) { if (_selectionLength > 0) { DeleteSelection(); } else if (_cursorPosition < Text.Length) { Text = Text.Remove(_cursorPosition, 1); } ResetCursorBlink(); Invalidate(); } e.Handled = true; break; case Key.Left: if (_cursorPosition > 0) { if (e.Modifiers.HasFlag(KeyModifiers.Shift)) { ExtendSelection(-1); } else { ClearSelection(); _cursorPosition--; } ResetCursorBlink(); Invalidate(); } e.Handled = true; break; case Key.Right: if (_cursorPosition < Text.Length) { if (e.Modifiers.HasFlag(KeyModifiers.Shift)) { ExtendSelection(1); } else { ClearSelection(); _cursorPosition++; } ResetCursorBlink(); Invalidate(); } e.Handled = true; break; case Key.Home: if (e.Modifiers.HasFlag(KeyModifiers.Shift)) { ExtendSelectionTo(0); } else { ClearSelection(); _cursorPosition = 0; } ResetCursorBlink(); Invalidate(); e.Handled = true; break; case Key.End: if (e.Modifiers.HasFlag(KeyModifiers.Shift)) { ExtendSelectionTo(Text.Length); } else { ClearSelection(); _cursorPosition = Text.Length; } ResetCursorBlink(); Invalidate(); 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; case Key.Enter: Completed?.Invoke(this, EventArgs.Empty); e.Handled = true; break; } } public override void OnPointerPressed(PointerEventArgs e) { Console.WriteLine($"[SkiaEntry] OnPointerPressed - Text='{Text}', Placeholder='{Placeholder}', IsEnabled={IsEnabled}, IsFocused={IsFocused}"); Console.WriteLine($"[SkiaEntry] Bounds={Bounds}, ScreenBounds={ScreenBounds}, e.X={e.X}, e.Y={e.Y}"); if (!IsEnabled) return; // Check if clicked on clear button if (ShowClearButton && !string.IsNullOrEmpty(Text) && IsFocused) { var clearButtonSize = 20f; var clearButtonMargin = 8f; var clearCenterX = Bounds.Right - clearButtonMargin - clearButtonSize / 2; var clearCenterY = Bounds.MidY; var dx = e.X - clearCenterX; var dy = e.Y - clearCenterY; if (dx * dx + dy * dy < (clearButtonSize / 2) * (clearButtonSize / 2)) { // Clear button clicked Text = ""; _cursorPosition = 0; _selectionLength = 0; Invalidate(); return; } } // Calculate cursor position from click using screen coordinates var screenBounds = ScreenBounds; var clickX = e.X - screenBounds.Left - Padding.Left + _scrollOffset; _cursorPosition = 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 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; } ResetCursorBlink(); 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 == '_'; } public override void OnPointerMoved(PointerEventArgs e) { if (!IsEnabled || !_isSelecting) return; // Extend selection to current mouse position var screenBounds = ScreenBounds; var clickX = e.X - screenBounds.Left - Padding.Left + _scrollOffset; var newPosition = GetCharacterIndexAtX(clickX); if (newPosition != _cursorPosition) { _cursorPosition = newPosition; _selectionLength = _cursorPosition - _selectionStart; ResetCursorBlink(); Invalidate(); } } public override void OnPointerReleased(PointerEventArgs e) { _isSelecting = false; } private int GetCharacterIndexAtX(float x) { if (string.IsNullOrEmpty(Text)) return 0; var fontStyle = GetFontStyle(); var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) ?? SKTypeface.Default; using var font = new SKFont(typeface, FontSize); using var paint = new SKPaint(font); var displayText = GetDisplayText(); for (int i = 0; i <= displayText.Length; i++) { var substring = displayText.Substring(0, i); var width = paint.MeasureText(substring); if (width >= x) { // Check if closer to current or previous character if (i > 0) { var prevWidth = paint.MeasureText(displayText.Substring(0, i - 1)); if (x - prevWidth < width - x) return i - 1; } return i; } } return displayText.Length; } private void DeleteSelection() { var start = Math.Min(_selectionStart, _selectionStart + _selectionLength); var length = Math.Abs(_selectionLength); Text = Text.Remove(start, length); _cursorPosition = start; _selectionLength = 0; } private void ClearSelection() { _selectionLength = 0; } private void ExtendSelection(int delta) { if (_selectionLength == 0) { _selectionStart = _cursorPosition; } _cursorPosition += delta; _selectionLength = _cursorPosition - _selectionStart; } private void ExtendSelectionTo(int position) { if (_selectionLength == 0) { _selectionStart = _cursorPosition; } _cursorPosition = position; _selectionLength = _cursorPosition - _selectionStart; } /// /// Selects all text. /// public void SelectAll() { _selectionStart = 0; _cursorPosition = Text.Length; _selectionLength = Text.Length; Invalidate(); } private void CopyToClipboard() { // Password fields should not allow copying if (IsPassword) return; 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() { // Password fields should not allow cutting if (IsPassword) return; CopyToClipboard(); DeleteSelection(); Invalidate(); } private void PasteFromClipboard() { // Get from system clipboard var text = SystemClipboard.GetText(); if (string.IsNullOrEmpty(text)) return; if (_selectionLength != 0) { DeleteSelection(); } // Check max length if (MaxLength > 0) { var remaining = MaxLength - Text.Length; text = text.Substring(0, Math.Min(text.Length, remaining)); } var newText = Text.Insert(_cursorPosition, text); var newPos = _cursorPosition + text.Length; Text = newText; _cursorPosition = newPos; Invalidate(); } public override void OnFocusGained() { base.OnFocusGained(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused); } public override void OnFocusLost() { base.OnFocusLost(); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal); } protected override SKSize MeasureOverride(SKSize availableSize) { var fontStyle = GetFontStyle(); var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) ?? SKTypeface.Default; using var font = new SKFont(typeface, FontSize); // Use font metrics for consistent height regardless of text content // This prevents size changes when placeholder disappears or text changes var metrics = font.Metrics; var textHeight = metrics.Descent - metrics.Ascent + metrics.Leading; return new SKSize( 200, // Default width, will be overridden by layout textHeight + Padding.Top + Padding.Bottom + BorderWidth * 2); } } /// /// Event args for text changed events. /// public class TextChangedEventArgs : EventArgs { public string OldTextValue { get; } public string NewTextValue { get; } public TextChangedEventArgs(string oldText, string newText) { OldTextValue = oldText; NewTextValue = newText; } }