Files
RobustToolbox/Robust.Client/UserInterface/Controls/LineEdit.cs
2021-03-10 16:55:12 +01:00

934 lines
30 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using Robust.Shared.ViewVariables;
namespace Robust.Client.UserInterface.Controls
{
/// <summary>
/// Allows the user to input and modify a line of text.
/// </summary>
public class LineEdit : Control
{
private const float BlinkTime = 0.5f;
private const float MouseScrollDelay = 0.001f;
public const string StylePropertyStyleBox = "stylebox";
public const string StyleClassLineEditNotEditable = "notEditable";
public const string StylePseudoClassPlaceholder = "placeholder";
// It is assumed that these two positions are NEVER inside a surrogate pair in the text buffer.
private int _cursorPosition;
private int _selectionStart;
private string _text = "";
private bool _editable = true;
private string? _placeHolder;
private int _drawOffset;
private float _cursorBlinkTimer;
private bool _cursorCurrentlyLit;
private readonly LineEditRenderBox _renderBox;
private bool _mouseSelectingText;
private float _lastMousePosition;
private bool IsPlaceHolderVisible => string.IsNullOrEmpty(_text) && _placeHolder != null;
public event Action<LineEditEventArgs>? OnTextChanged;
public event Action<LineEditEventArgs>? OnTextEntered;
public event Action<LineEditEventArgs>? OnFocusEnter;
public event Action<LineEditEventArgs>? OnFocusExit;
/// <summary>
/// Determines whether the LineEdit text gets changed by the input text.
/// </summary>
public Func<string, bool>? IsValid { get; set; }
/// <summary>
/// The actual text currently stored in the LineEdit.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
public string Text
{
get => _text;
set
{
if (value == null)
{
value = "";
}
if (!SetText(value))
{
return;
}
_cursorPosition = 0;
_selectionStart = 0;
_updatePseudoClass();
}
}
/// <summary>
/// The text
/// </summary>
public ReadOnlySpan<char> SelectedText
{
get
{
var lower = SelectionLower;
return _text.AsSpan(lower, SelectionLength);
}
}
public int SelectionLength => Math.Abs(_selectionStart - _cursorPosition);
[ViewVariables(VVAccess.ReadWrite)]
public bool Editable
{
get => _editable;
set
{
_editable = value;
if (_editable)
{
DefaultCursorShape = CursorShape.IBeam;
RemoveStyleClass(StyleClassLineEditNotEditable);
}
else
{
DefaultCursorShape = CursorShape.Arrow;
AddStyleClass(StyleClassLineEditNotEditable);
}
}
}
[ViewVariables(VVAccess.ReadWrite)]
public string? PlaceHolder
{
get => _placeHolder;
set
{
_placeHolder = value;
_updatePseudoClass();
}
}
public int CursorPosition
{
get => _cursorPosition;
set
{
var clamped = MathHelper.Clamp(value, 0, _text.Length);
if (_text.Length != 0 && _text.Length != clamped && !Rune.TryGetRuneAt(_text, clamped, out _))
throw new ArgumentException("Cannot set cursor inside surrogate pair.");
_cursorPosition = clamped;
_selectionStart = _cursorPosition;
}
}
public int SelectionStart
{
get => _selectionStart;
set
{
var clamped = MathHelper.Clamp(value, 0, _text.Length);
if (_text.Length != 0 && _text.Length != clamped && !Rune.TryGetRuneAt(_text, clamped, out _))
throw new ArgumentException("Cannot set cursor inside surrogate pair.");
_selectionStart = clamped;
}
}
public int SelectionLower => Math.Min(_selectionStart, _cursorPosition);
public int SelectionUpper => Math.Max(_selectionStart, _cursorPosition);
public bool IgnoreNext { get; set; }
// TODO:
// I decided to not implement the entire LineEdit API yet,
// since most of it won't be used yet (if at all).
// Feel free to implement wrappers for all the other properties!
// Future me reporting, thanks past me.
// Second future me reporting, thanks again.
// Third future me is here to say thanks.
// Fourth future me is here to continue the tradition.
public LineEdit()
{
MouseFilter = MouseFilterMode.Stop;
CanKeyboardFocus = true;
KeyboardFocusOnClick = true;
DefaultCursorShape = CursorShape.IBeam;
AddChild(_renderBox = new LineEditRenderBox(this));
}
public void Clear()
{
Text = "";
}
public void InsertAtCursor(string text)
{
// Strip newlines.
var chars = new List<char>(text.Length);
foreach (var chr in text)
{
if (chr == '\n')
{
continue;
}
chars.Add(chr);
}
text = new string(chars.ToArray());
var lower = SelectionLower;
var newContents = Text[..lower] + text + Text[SelectionUpper..];
if (!SetText(newContents))
{
return;
}
_selectionStart = _cursorPosition = lower + chars.Count;
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
_updatePseudoClass();
}
/// <remarks>
/// Does not fix cursor positions, those will have to be adjusted manually.
/// </remarks>>
protected bool SetText(string newText)
{
if (IsValid != null && !IsValid(newText))
{
return false;
}
_text = newText;
return true;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
_cursorBlinkTimer -= args.DeltaSeconds;
if (_cursorBlinkTimer <= 0)
{
_cursorBlinkTimer += BlinkTime;
_cursorCurrentlyLit = !_cursorCurrentlyLit;
}
if (_mouseSelectingText)
{
var style = _getStyleBox();
var contentBox = style.GetContentBox(PixelSizeBox);
if (_lastMousePosition < contentBox.Left)
{
_drawOffset = Math.Max(0, _drawOffset - (int) Math.Ceiling(args.DeltaSeconds / MouseScrollDelay));
}
else if (_lastMousePosition > contentBox.Right)
{
// Will get clamped inside rendering code.
_drawOffset += (int) Math.Ceiling(args.DeltaSeconds / MouseScrollDelay);
}
var index = GetIndexAtPos(MathHelper.Clamp(_lastMousePosition, contentBox.Left, contentBox.Right));
_cursorPosition = index;
}
}
protected override Vector2 MeasureOverride(Vector2 availableSize)
{
var font = _getFont();
var style = _getStyleBox();
return new Vector2(0, font.GetHeight(UIScale) / UIScale) + style.MinimumSize / UIScale;
}
protected override Vector2 ArrangeOverride(Vector2 finalSize)
{
var style = _getStyleBox();
_renderBox.ArrangePixel(
(UIBox2i) style.GetContentBox(
UIBox2.FromDimensions(Vector2.Zero, finalSize * UIScale)));
return finalSize;
}
protected internal override void TextEntered(GUITextEventArgs args)
{
base.TextEntered(args);
if (!Editable)
{
return;
}
if (IgnoreNext)
{
IgnoreNext = false;
return;
}
InsertAtCursor(args.AsRune.ToString());
}
protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
{
base.KeyBindDown(args);
if (args.Function != EngineKeyFunctions.UIClick && args.Function != EngineKeyFunctions.TextCursorSelect)
{
if (!HasKeyboardFocus())
{
return;
}
if (args.Function == EngineKeyFunctions.TextBackspace)
{
if (Editable)
{
var changed = false;
if (_selectionStart != _cursorPosition)
{
_text = _text.Remove(SelectionLower, SelectionLength);
_cursorPosition = SelectionLower;
changed = true;
}
else if (_cursorPosition != 0)
{
var remPos = _cursorPosition - 1;
var remAmt = 1;
// If this is a low surrogate remove two chars to remove the whole pair.
if (char.IsLowSurrogate(_text[remPos]))
{
remPos -= 1;
remAmt = 2;
}
_text = _text.Remove(remPos, remAmt);
_cursorPosition -= remAmt;
changed = true;
}
if (changed)
{
_selectionStart = _cursorPosition;
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
_updatePseudoClass();
}
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextDelete)
{
if (Editable)
{
var changed = false;
if (_selectionStart != _cursorPosition)
{
_text = _text.Remove(SelectionLower, SelectionLength);
_cursorPosition = SelectionLower;
changed = true;
}
else if (_cursorPosition < _text.Length)
{
var remAmt = 1;
if (char.IsHighSurrogate(_text[_cursorPosition]))
remAmt = 2;
_text = _text.Remove(_cursorPosition, remAmt);
changed = true;
}
if (changed)
{
_selectionStart = _cursorPosition;
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
_updatePseudoClass();
}
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorLeft)
{
if (_selectionStart != _cursorPosition)
{
_cursorPosition = _selectionStart = SelectionLower;
}
else
{
ShiftCursorLeft();
_selectionStart = _cursorPosition;
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorRight)
{
if (_selectionStart != _cursorPosition)
{
_cursorPosition = _selectionStart = SelectionUpper;
}
else
{
ShiftCursorRight();
_selectionStart = _cursorPosition;
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorWordLeft)
{
_selectionStart = _cursorPosition = PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorWordRight)
{
_selectionStart = _cursorPosition = NextWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorBegin)
{
_selectionStart = _cursorPosition = 0;
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorEnd)
{
_selectionStart = _cursorPosition = _text.Length;
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectLeft)
{
ShiftCursorLeft();
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectRight)
{
ShiftCursorRight();
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectWordLeft)
{
_cursorPosition = PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectWordRight)
{
_cursorPosition = NextWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectBegin)
{
_cursorPosition = 0;
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectEnd)
{
_cursorPosition = _text.Length;
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextSubmit)
{
if (Editable)
{
OnTextEntered?.Invoke(new LineEditEventArgs(this, _text));
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextPaste)
{
if (Editable)
{
var clipboard = IoCManager.Resolve<IClipboardManager>();
var text = clipboard.GetText();
if (text != null)
{
InsertAtCursor(text);
}
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCut)
{
if (Editable || SelectionLower != SelectionUpper)
{
var clipboard = IoCManager.Resolve<IClipboardManager>();
var text = SelectedText;
if (!text.IsEmpty)
{
clipboard.SetText(SelectedText.ToString());
}
InsertAtCursor("");
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCopy)
{
var clipboard = IoCManager.Resolve<IClipboardManager>();
var text = SelectedText;
if (!text.IsEmpty)
{
clipboard.SetText(text.ToString());
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextSelectAll)
{
_cursorPosition = _text.Length;
_selectionStart = 0;
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextReleaseFocus)
{
ReleaseKeyboardFocus();
args.Handle();
return;
}
}
else
{
_mouseSelectingText = true;
_lastMousePosition = args.RelativePosition.X;
// Find closest cursor position under mouse.
var index = GetIndexAtPos(args.RelativePosition.X);
_cursorPosition = index;
if (args.Function != EngineKeyFunctions.TextCursorSelect)
{
_selectionStart = index;
}
args.Handle();
}
// Reset this so the cursor is always visible immediately after a keybind is pressed.
_resetCursorBlink();
void ShiftCursorLeft()
{
if (_cursorPosition == 0)
return;
_cursorPosition -= 1;
if (char.IsLowSurrogate(_text[_cursorPosition]))
_cursorPosition -= 1;
}
void ShiftCursorRight()
{
if (_cursorPosition == _text.Length)
return;
_cursorPosition += 1;
if (char.IsLowSurrogate(_text[_cursorPosition]))
_cursorPosition += 1;
}
}
protected internal override void KeyBindUp(GUIBoundKeyEventArgs args)
{
base.KeyBindUp(args);
if (args.Function == EngineKeyFunctions.UIClick || args.Function == EngineKeyFunctions.TextCursorSelect)
{
_mouseSelectingText = false;
}
}
protected internal override void MouseMove(GUIMouseMoveEventArgs args)
{
base.MouseMove(args);
_lastMousePosition = args.RelativePosition.X;
}
private int GetIndexAtPos(float horizontalPos)
{
var style = _getStyleBox();
var contentBox = style.GetContentBox(PixelSizeBox);
var clickPosX = horizontalPos * UIScale;
var font = _getFont();
var index = 0;
var chrPosX = contentBox.Left - _drawOffset;
var lastChrPostX = contentBox.Left - _drawOffset;
foreach (var rune in _text.EnumerateRunes())
{
if (!font.TryGetCharMetrics(rune, UIScale, out var metrics))
{
index += rune.Utf16SequenceLength;
continue;
}
if (chrPosX > clickPosX)
{
break;
}
lastChrPostX = chrPosX;
chrPosX += metrics.Advance;
index += rune.Utf16SequenceLength;
if (chrPosX > contentBox.Right)
{
break;
}
}
// Distance between the right side of the glyph overlapping the mouse and the mouse.
var distanceRight = chrPosX - clickPosX;
// Same but left side.
var distanceLeft = clickPosX - lastChrPostX;
// If the mouse is closer to the left of the glyph we lower the index one, so we select before that glyph.
if (index > 0 && distanceRight > distanceLeft)
{
index -= 1;
if (char.IsLowSurrogate(_text[index]))
index -= 1;
}
return index;
}
protected internal override void KeyboardFocusEntered()
{
base.KeyboardFocusEntered();
// Reset this so the cursor is always visible immediately after gaining focus..
_resetCursorBlink();
OnFocusEnter?.Invoke(new LineEditEventArgs(this, _text));
}
protected internal override void KeyboardFocusExited()
{
base.KeyboardFocusExited();
OnFocusExit?.Invoke(new LineEditEventArgs(this, _text));
}
[Pure]
private Font _getFont()
{
if (TryGetStyleProperty<Font>("font", out var font))
{
return font;
}
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
[Pure]
private StyleBox _getStyleBox()
{
if (TryGetStyleProperty<StyleBox>(StylePropertyStyleBox, out var box))
{
return box;
}
return UserInterfaceManager.ThemeDefaults.LineEditBox;
}
[Pure]
private Color _getFontColor()
{
if (TryGetStyleProperty("font-color", out Color color))
{
return color;
}
return Color.White;
}
private void _updatePseudoClass()
{
SetOnlyStylePseudoClass(IsPlaceHolderVisible ? StylePseudoClassPlaceholder : null);
}
protected internal override void Draw(DrawingHandleScreen handle)
{
base.Draw(handle);
_getStyleBox().Draw(handle, PixelSizeBox);
}
// Approach for NextWordPosition and PrevWordPosition taken from Avalonia.
internal static int NextWordPosition(string str, int cursor)
{
if (cursor >= str.Length)
{
return str.Length;
}
var charClass = GetCharClass(Rune.GetRuneAt(str, cursor));
var i = cursor;
IterForward(charClass);
IterForward(CharClass.Whitespace);
return i;
void IterForward(CharClass cClass)
{
while (i < str.Length)
{
var rune = Rune.GetRuneAt(str, i);
if (GetCharClass(rune) != cClass)
break;
i += rune.Utf16SequenceLength;
}
}
}
internal static int PrevWordPosition(string str, int cursor)
{
if (cursor == 0)
{
return 0;
}
var startRune = GetRuneBackwards(str, cursor - 1);
var charClass = GetCharClass(startRune);
var i = cursor;
IterBackward();
if (charClass == CharClass.Whitespace)
{
if (!Rune.TryGetRuneAt(str, i - 1, out var midRune))
midRune = Rune.GetRuneAt(str, i - 2);
charClass = GetCharClass(midRune);
IterBackward();
}
return i;
void IterBackward()
{
while (i > 0)
{
var rune = GetRuneBackwards(str, i - 1);
if (GetCharClass(rune) != charClass)
break;
i -= rune.Utf16SequenceLength;
}
}
static Rune GetRuneBackwards(string str, int i)
{
return Rune.TryGetRuneAt(str, i, out var rune) ? rune : Rune.GetRuneAt(str, i - 1);
}
}
internal static CharClass GetCharClass(Rune rune)
{
if (Rune.IsWhiteSpace(rune))
{
return CharClass.Whitespace;
}
if (Rune.IsLetterOrDigit(rune))
{
return CharClass.AlphaNumeric;
}
return CharClass.Other;
}
internal enum CharClass : byte
{
Other,
AlphaNumeric,
Whitespace
}
public class LineEditEventArgs : EventArgs
{
public LineEdit Control { get; }
public string Text { get; }
public LineEditEventArgs(LineEdit control, string text)
{
Control = control;
Text = text;
}
}
/// <summary>
/// Use a separate control to do the rendering to make use of RectClipContent,
/// so that we can clip characters in half.
/// </summary>
private sealed class LineEditRenderBox : Control
{
private readonly LineEdit _master;
public LineEditRenderBox(LineEdit master)
{
_master = master;
RectClipContent = true;
}
protected internal override void Draw(DrawingHandleScreen handle)
{
base.Draw(handle);
var contentBox = PixelSizeBox;
var font = _master._getFont();
var renderedTextColor = _master._getFontColor();
var offsetY = (contentBox.Height - font.GetHeight(UIScale)) / 2;
var renderedText = _master.IsPlaceHolderVisible ? _master._placeHolder! : _master._text;
DebugTools.AssertNotNull(renderedText);
ref var drawOffset = ref _master._drawOffset;
// Go through the entire text once to find length/positional data of cursor.
var count = 0;
var posX = 0;
var actualCursorPosition = 0;
var actualSelectionStartPosition = 0;
foreach (var chr in renderedText.EnumerateRunes())
{
if (!font.TryGetCharMetrics(chr, UIScale, out var metrics))
{
count += 1;
continue;
}
posX += metrics.Advance;
count += chr.Utf16SequenceLength;
if (count == _master._cursorPosition)
{
actualCursorPosition = posX;
}
if (count == _master._selectionStart)
{
actualSelectionStartPosition = posX;
}
}
var totalLength = posX;
// Shift drawOffset around to fill as much as possible.
var end = totalLength - drawOffset;
if (end + 1 < contentBox.Width)
{
drawOffset = Math.Max(0, drawOffset - (contentBox.Width - end));
}
// Shift drawOffset around so that cursor is always visible.
if (actualCursorPosition < drawOffset)
{
drawOffset -= drawOffset - actualCursorPosition;
}
else if (actualCursorPosition >= contentBox.Width + drawOffset)
{
drawOffset += actualCursorPosition - (contentBox.Width + drawOffset - 1);
}
// Apply drawOffset to positional data.
actualCursorPosition -= drawOffset;
actualSelectionStartPosition -= drawOffset;
// Actually render.
var baseLine = (-drawOffset, offsetY + font.GetAscent(UIScale)) +
contentBox.TopLeft;
foreach (var rune in renderedText.EnumerateRunes())
{
if (!font.TryGetCharMetrics(rune, UIScale, out var metrics))
{
continue;
}
if (baseLine.X > contentBox.Right)
{
// Past the right edge, not gonna render anything anymore.
break;
}
// Make sure we're not off the left edge of the box.
if (baseLine.X + metrics.BearingX + metrics.Width >= contentBox.Left)
{
font.DrawChar(handle, rune, baseLine, UIScale, renderedTextColor);
}
baseLine += (metrics.Advance, 0);
}
// Draw cursor/selection.
if (_master.HasKeyboardFocus())
{
var selectionLower = Math.Min(actualSelectionStartPosition, actualCursorPosition);
var selectionUpper = Math.Max(actualSelectionStartPosition, actualCursorPosition);
if (selectionLower != selectionUpper)
{
handle.DrawRect(new UIBox2(selectionLower, contentBox.Top, selectionUpper, contentBox.Bottom),
Color.CornflowerBlue.WithAlpha(0.25f));
}
if (_master._cursorCurrentlyLit)
{
handle.DrawRect(
new UIBox2(actualCursorPosition, contentBox.Top, actualCursorPosition + 1,
contentBox.Bottom), Color.White);
}
}
}
}
private void _resetCursorBlink()
{
_cursorCurrentlyLit = true;
_cursorBlinkTimer = BlinkTime;
}
}
}