528 lines
16 KiB
C#
528 lines
16 KiB
C#
// Licensed to the .NET Foundation under one or more agreements.
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
|
|
using SkiaSharp;
|
|
|
|
namespace Microsoft.Maui.Platform;
|
|
|
|
/// <summary>
|
|
/// Skia-rendered multiline text editor control.
|
|
/// </summary>
|
|
public class SkiaEditor : SkiaView
|
|
{
|
|
private string _text = "";
|
|
private string _placeholder = "";
|
|
private int _cursorPosition;
|
|
private int _selectionStart = -1;
|
|
private int _selectionLength;
|
|
private float _scrollOffsetY;
|
|
private bool _isReadOnly;
|
|
private int _maxLength = -1;
|
|
private bool _cursorVisible = true;
|
|
private DateTime _lastCursorBlink = DateTime.Now;
|
|
|
|
// Cached line information
|
|
private List<string> _lines = new() { "" };
|
|
private List<float> _lineHeights = new();
|
|
|
|
// Styling
|
|
public SKColor TextColor { get; set; } = SKColors.Black;
|
|
public SKColor PlaceholderColor { get; set; } = new SKColor(0x80, 0x80, 0x80);
|
|
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
|
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x60);
|
|
public SKColor CursorColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
|
public string FontFamily { get; set; } = "Sans";
|
|
public float FontSize { get; set; } = 14;
|
|
public float LineHeight { get; set; } = 1.4f;
|
|
public float CornerRadius { get; set; } = 4;
|
|
public float Padding { get; set; } = 12;
|
|
public bool AutoSize { get; set; }
|
|
|
|
public string Text
|
|
{
|
|
get => _text;
|
|
set
|
|
{
|
|
var newText = value ?? "";
|
|
if (_maxLength > 0 && newText.Length > _maxLength)
|
|
{
|
|
newText = newText.Substring(0, _maxLength);
|
|
}
|
|
|
|
if (_text != newText)
|
|
{
|
|
_text = newText;
|
|
UpdateLines();
|
|
_cursorPosition = Math.Min(_cursorPosition, _text.Length);
|
|
TextChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
}
|
|
}
|
|
|
|
public string Placeholder
|
|
{
|
|
get => _placeholder;
|
|
set { _placeholder = value ?? ""; Invalidate(); }
|
|
}
|
|
|
|
public bool IsReadOnly
|
|
{
|
|
get => _isReadOnly;
|
|
set { _isReadOnly = value; Invalidate(); }
|
|
}
|
|
|
|
public int MaxLength
|
|
{
|
|
get => _maxLength;
|
|
set { _maxLength = value; }
|
|
}
|
|
|
|
public int CursorPosition
|
|
{
|
|
get => _cursorPosition;
|
|
set
|
|
{
|
|
_cursorPosition = Math.Clamp(value, 0, _text.Length);
|
|
EnsureCursorVisible();
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
public event EventHandler? TextChanged;
|
|
public event EventHandler? Completed;
|
|
|
|
public SkiaEditor()
|
|
{
|
|
IsFocusable = true;
|
|
}
|
|
|
|
private void UpdateLines()
|
|
{
|
|
_lines.Clear();
|
|
if (string.IsNullOrEmpty(_text))
|
|
{
|
|
_lines.Add("");
|
|
return;
|
|
}
|
|
|
|
var currentLine = "";
|
|
foreach (var ch in _text)
|
|
{
|
|
if (ch == '\n')
|
|
{
|
|
_lines.Add(currentLine);
|
|
currentLine = "";
|
|
}
|
|
else
|
|
{
|
|
currentLine += ch;
|
|
}
|
|
}
|
|
_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; // +1 for newline
|
|
}
|
|
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)
|
|
{
|
|
// Handle cursor blinking
|
|
if (IsFocused && (DateTime.Now - _lastCursorBlink).TotalMilliseconds > 500)
|
|
{
|
|
_cursorVisible = !_cursorVisible;
|
|
_lastCursorBlink = DateTime.Now;
|
|
}
|
|
|
|
// Draw background
|
|
using var bgPaint = new SKPaint
|
|
{
|
|
Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5),
|
|
Style = SKPaintStyle.Fill,
|
|
IsAntialias = true
|
|
};
|
|
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint);
|
|
|
|
// Draw border
|
|
using var borderPaint = new SKPaint
|
|
{
|
|
Color = IsFocused ? CursorColor : 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 + Padding,
|
|
bounds.Top + Padding,
|
|
bounds.Right - Padding,
|
|
bounds.Bottom - Padding);
|
|
|
|
canvas.Save();
|
|
canvas.ClipRect(contentRect);
|
|
canvas.Translate(0, -_scrollOffsetY);
|
|
|
|
if (string.IsNullOrEmpty(_text) && !string.IsNullOrEmpty(_placeholder))
|
|
{
|
|
// Draw placeholder
|
|
using var placeholderPaint = new SKPaint(font)
|
|
{
|
|
Color = PlaceholderColor,
|
|
IsAntialias = true
|
|
};
|
|
canvas.DrawText(_placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint);
|
|
}
|
|
else
|
|
{
|
|
// Draw text with selection
|
|
using var textPaint = new SKPaint(font)
|
|
{
|
|
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
|
IsAntialias = true
|
|
};
|
|
using var selectionPaint = new SKPaint
|
|
{
|
|
Color = 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)
|
|
{
|
|
var selEnd = _selectionStart + _selectionLength;
|
|
var lineStart = charIndex;
|
|
var lineEnd = charIndex + line.Length;
|
|
|
|
if (selEnd > lineStart && _selectionStart < lineEnd)
|
|
{
|
|
var selStartInLine = Math.Max(0, _selectionStart - 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);
|
|
}
|
|
}
|
|
|
|
// Draw line text
|
|
canvas.DrawText(line, x, y, textPaint);
|
|
|
|
// Draw cursor if on this line
|
|
if (IsFocused && _cursorVisible)
|
|
{
|
|
var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition);
|
|
if (cursorLine == lineIndex)
|
|
{
|
|
var cursorX = x + MeasureText(line.Substring(0, Math.Min(cursorCol, line.Length)), font);
|
|
using var cursorPaint = new SKPaint
|
|
{
|
|
Color = CursorColor,
|
|
Style = SKPaintStyle.Stroke,
|
|
StrokeWidth = 2,
|
|
IsAntialias = true
|
|
};
|
|
canvas.DrawLine(cursorX, y - FontSize + 2, cursorX, y + 2, cursorPaint);
|
|
}
|
|
}
|
|
|
|
y += lineSpacing;
|
|
charIndex += line.Length + 1; // +1 for newline
|
|
}
|
|
}
|
|
|
|
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 scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight));
|
|
var scrollbarY = bounds.Top + Padding + (_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 lineSpacing = FontSize * LineHeight;
|
|
var cursorY = line * lineSpacing;
|
|
var viewHeight = Bounds.Height - Padding * 2;
|
|
|
|
if (cursorY < _scrollOffsetY)
|
|
{
|
|
_scrollOffsetY = cursorY;
|
|
}
|
|
else if (cursorY + lineSpacing > _scrollOffsetY + viewHeight)
|
|
{
|
|
_scrollOffsetY = cursorY + lineSpacing - viewHeight;
|
|
}
|
|
}
|
|
|
|
public override void OnPointerPressed(PointerEventArgs e)
|
|
{
|
|
if (!IsEnabled) return;
|
|
|
|
// Request focus by notifying parent
|
|
IsFocused = true;
|
|
|
|
// Calculate cursor position from click
|
|
var contentX = e.X - Bounds.Left - Padding;
|
|
var contentY = e.Y - Bounds.Top - Padding + _scrollOffsetY;
|
|
|
|
var lineSpacing = FontSize * 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;
|
|
|
|
// Find closest character position
|
|
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);
|
|
_selectionStart = -1;
|
|
_selectionLength = 0;
|
|
_cursorVisible = true;
|
|
_lastCursorBlink = DateTime.Now;
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
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 && _cursorPosition > 0)
|
|
{
|
|
Text = _text.Remove(_cursorPosition - 1, 1);
|
|
_cursorPosition--;
|
|
EnsureCursorVisible();
|
|
}
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Delete:
|
|
if (!_isReadOnly && _cursorPosition < _text.Length)
|
|
{
|
|
Text = _text.Remove(_cursorPosition, 1);
|
|
}
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Tab:
|
|
if (!_isReadOnly)
|
|
{
|
|
InsertText(" "); // 4 spaces for tab
|
|
}
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
public override void OnTextInput(TextInputEventArgs e)
|
|
{
|
|
if (!IsEnabled || _isReadOnly) return;
|
|
|
|
if (!string.IsNullOrEmpty(e.Text))
|
|
{
|
|
InsertText(e.Text);
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
private void InsertText(string text)
|
|
{
|
|
if (_selectionLength > 0)
|
|
{
|
|
// Replace selection
|
|
_text = _text.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 lineSpacing = FontSize * LineHeight;
|
|
var totalHeight = _lines.Count * lineSpacing;
|
|
var viewHeight = Bounds.Height - Padding * 2;
|
|
var maxScroll = Math.Max(0, totalHeight - viewHeight);
|
|
|
|
_scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll);
|
|
Invalidate();
|
|
}
|
|
|
|
protected override SKSize MeasureOverride(SKSize availableSize)
|
|
{
|
|
if (AutoSize)
|
|
{
|
|
var lineSpacing = FontSize * LineHeight;
|
|
var height = Math.Max(lineSpacing + Padding * 2, _lines.Count * lineSpacing + Padding * 2);
|
|
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);
|
|
}
|
|
}
|