// 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;
}
}