// 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. /// public class SkiaEntry : SkiaView { private string _text = ""; private int _cursorPosition; private int _selectionStart; private int _selectionLength; private float _scrollOffset; private DateTime _cursorBlinkTime = DateTime.UtcNow; private bool _cursorVisible = true; public string Text { get => _text; set { if (_text != value) { var oldText = _text; _text = value ?? ""; _cursorPosition = Math.Min(_cursorPosition, _text.Length); TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); Invalidate(); } } } public string Placeholder { get; set; } = ""; public SKColor PlaceholderColor { get; set; } = new SKColor(0x9E, 0x9E, 0x9E); public SKColor TextColor { get; set; } = SKColors.Black; public new SKColor BackgroundColor { get; set; } = SKColors.White; public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD); public SKColor FocusedBorderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x80); public SKColor CursorColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); public string FontFamily { get; set; } = "Sans"; public float FontSize { get; set; } = 14; public bool IsBold { get; set; } public bool IsItalic { get; set; } public float CharacterSpacing { get; set; } public float CornerRadius { get; set; } = 4; public float BorderWidth { get; set; } = 1; public SKRect Padding { get; set; } = new SKRect(12, 8, 12, 8); public bool IsPassword { get; set; } public char PasswordChar { get; set; } = '●'; public int MaxLength { get; set; } = 0; // 0 = unlimited public bool IsReadOnly { get; set; } public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start; public TextAlignment VerticalTextAlignment { get; set; } = TextAlignment.Center; public bool ShowClearButton { get; set; } public int CursorPosition { get => _cursorPosition; set { _cursorPosition = Math.Clamp(value, 0, _text.Length); ResetCursorBlink(); Invalidate(); } } public int SelectionLength { get => _selectionLength; set { _selectionLength = value; Invalidate(); } } public event EventHandler? TextChanged; public event EventHandler? Completed; public SkiaEntry() { IsFocusable = true; } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Draw background using var bgPaint = new SKPaint { Color = BackgroundColor, 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 }; // Apply character spacing if set if (CharacterSpacing > 0) { // Character spacing applied via SKPaint } 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 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; } 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; // 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 oldText = _text; _text = _text.Insert(_cursorPosition, insertText); _cursorPosition += insertText.Length; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); 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 oldText = _text; _text = _text.Remove(_cursorPosition - 1, 1); _cursorPosition--; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); } ResetCursorBlink(); Invalidate(); } e.Handled = true; break; case Key.Delete: if (!IsReadOnly) { if (_selectionLength > 0) { DeleteSelection(); } else if (_cursorPosition < _text.Length) { var oldText = _text; _text = _text.Remove(_cursorPosition, 1); TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); } 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) { 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 var oldText = _text; _text = ""; _cursorPosition = 0; _selectionLength = 0; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); Invalidate(); return; } } // Calculate cursor position from click var clickX = e.X - Bounds.Left - Padding.Left + _scrollOffset; _cursorPosition = GetCharacterIndexAtX(clickX); _selectionStart = _cursorPosition; _selectionLength = 0; ResetCursorBlink(); Invalidate(); } 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); var oldText = _text; _text = _text.Remove(start, length); _cursorPosition = start; _selectionLength = 0; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); } 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; } public void SelectAll() { _selectionStart = 0; _cursorPosition = _text.Length; _selectionLength = _text.Length; Invalidate(); } 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); // TODO: Implement actual clipboard using X11 // For now, store in a static field ClipboardText = selectedText; } private void CutToClipboard() { CopyToClipboard(); DeleteSelection(); Invalidate(); } private void PasteFromClipboard() { // TODO: Get from actual X11 clipboard var text = ClipboardText; 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 oldText = _text; _text = _text.Insert(_cursorPosition, text); _cursorPosition += text.Length; TextChanged?.Invoke(this, new TextChangedEventArgs(oldText, _text)); Invalidate(); } // Temporary clipboard storage - will be replaced with X11 clipboard private static string ClipboardText { get; set; } = ""; 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); using var paint = new SKPaint(font); var textBounds = new SKRect(); var measureText = !string.IsNullOrEmpty(_text) ? _text : Placeholder; if (string.IsNullOrEmpty(measureText)) measureText = "Tg"; // Standard height measurement paint.MeasureText(measureText, ref textBounds); return new SKSize( 200, // Default width, will be overridden by layout textBounds.Height + 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; } }