// Copyright (c) 2012-2021 Wojciech Figat. All rights reserved.
using System;
using FlaxEngine.Assertions;
using FlaxEngine.Utilities;
namespace FlaxEngine.GUI
{
///
/// Base class for all text box controls which can gather text input from the user.
///
public abstract class TextBoxBase : ContainerControl
{
///
/// The text separators (used for words skipping).
///
protected static readonly char[] Separators =
{
' ',
'.',
',',
'\t',
'\r',
'\n',
':',
';',
'\'',
'\"',
')',
'(',
'/',
'\\',
'>',
'<',
};
///
/// Default height of the text box
///
public static float DefaultHeight = 18;
///
/// Left and right margin for text inside the text box bounds rectangle
///
public static float DefaultMargin = 4;
///
/// The current text value.
///
protected string _text = string.Empty;
///
/// The text value captured when user started editing text. Used to detect content modification.
///
protected string _onStartEditValue;
///
/// Flag used to indicate whenever user is editing the text.
///
protected bool _isEditing;
///
/// The view offset
///
protected Vector2 _viewOffset;
///
/// The target view offset.
///
protected Vector2 _targetViewOffset;
///
/// The text size calculated from font.
///
protected Vector2 _textSize;
///
/// Flag used to indicate whenever text can contain multiple lines.
///
protected bool _isMultiline;
///
/// Flag used to indicate whenever text is read-only and cannot be modified by the user.
///
protected bool _isReadOnly;
///
/// The maximum length of the text.
///
protected int _maxLength;
///
/// Flag used to indicate whenever user is selecting text.
///
protected bool _isSelecting;
///
/// The selection start position (character index).
///
protected int _selectionStart;
///
/// The selection end position (character index).
///
protected int _selectionEnd;
///
/// The animate time for selection.
///
protected float _animateTime;
///
/// Event fired when text gets changed
///
public event Action TextChanged;
///
/// Event fired when text gets changed after editing (user accepted entered value).
///
public event Action EditEnd;
///
/// Event fired when text gets changed after editing (user accepted entered value).
///
public event Action TextBoxEditEnd;
///
/// Gets or sets a value indicating whether this is a multiline text box control.
///
[EditorOrder(40), Tooltip("If checked, the textbox will support multiline text input.")]
public bool IsMultiline
{
get => _isMultiline;
set
{
if (_isMultiline != value)
{
_isMultiline = value;
OnIsMultilineChanged();
Deselect();
if (!_isMultiline)
{
var lines = _text.Split('\n');
_text = lines[0];
}
}
}
}
///
/// Gets or sets the maximum number of characters the user can type into the text box control.
///
[EditorOrder(50), Tooltip("The maximum number of characters the user can type into the text box control.")]
public int MaxLength
{
get => _maxLength;
set
{
if (_maxLength <= 0)
throw new ArgumentOutOfRangeException(nameof(MaxLength));
if (_maxLength != value)
{
_maxLength = value;
// Cut too long text
if (_text.Length > _maxLength)
{
Text = _text.Substring(0, _maxLength);
}
}
}
}
///
/// Gets or sets a value indicating whether text in the text box is read-only.
///
[EditorOrder(60), Tooltip("If checked, text in the text box is read-only.")]
public bool IsReadOnly
{
get => _isReadOnly;
set
{
if (_isReadOnly != value)
{
_isReadOnly = value;
OnIsReadOnlyChanged();
}
}
}
///
/// Gets or sets textbox background color when the control is selected (has focus).
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The textbox background color when the control is selected (has focus).")]
public Color BackgroundSelectedColor { get; set; }
///
/// Gets or sets the color of the caret (Transparent if not used).
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The color of the caret (Transparent if not used).")]
public Color CaretColor { get; set; }
///
/// Gets or sets the speed of the caret flashing animation.
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The speed of the caret flashing animation.")]
public float CaretFlashSpeed { get; set; } = 6.0f;
///
/// Gets or sets the speed of the selection background flashing animation.
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The speed of the selection background flashing animation.")]
public float BackgroundSelectedFlashSpeed { get; set; } = 6.0f;
///
/// Gets or sets the color of the border (Transparent if not used).
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The color of the border (Transparent if not used).")]
public Color BorderColor { get; set; }
///
/// Gets or sets the color of the border when control is focused (Transparent if not used).
///
[EditorDisplay("Style"), EditorOrder(2000), Tooltip("The color of the border when control is focused (Transparent if not used)")]
public Color BorderSelectedColor { get; set; }
///
/// Gets the size of the text (cached).
///
public Vector2 TextSize => _textSize;
///
/// Occurs when target view offset gets changed.
///
public event Action TargetViewOffsetChanged;
///
/// Gets the current view offset (text scrolling offset). Includes the smoothing.
///
public Vector2 ViewOffset => _viewOffset;
///
/// Gets or sets the target view offset (text scrolling offset).
///
[NoAnimate, NoSerialize]
public Vector2 TargetViewOffset
{
get => _targetViewOffset;
set
{
value = Vector2.Round(value);
if (Vector2.NearEqual(ref value, ref _targetViewOffset))
return;
_targetViewOffset = _viewOffset = value;
OnTargetViewOffsetChanged();
}
}
///
/// Gets or sets text property.
///
[EditorOrder(0), MultilineText, Tooltip("The entered text.")]
public string Text
{
get => _text;
set
{
// Skip set if user is editing value
if (_isEditing)
return;
SetText(value);
}
}
///
/// Sets the text.
///
/// The value.
protected void SetText(string value)
{
// Prevent from null problems
if (value == null)
value = string.Empty;
// Filter text
if (value.IndexOf('\r') != -1)
value = value.Replace("\r", "");
// Clamp length
if (value.Length > MaxLength)
value = value.Substring(0, MaxLength);
// Ensure to use only single line
if (_isMultiline == false && value.Length > 0)
{
// Extract only the first line
value = value.GetLines()[0];
}
if (_text != value)
{
Deselect();
ResetViewOffset();
_text = value;
OnTextChanged();
}
}
///
/// Gets length of the text
///
public int TextLength => _text.Length;
///
/// Gets the currently selected text in the control.
///
public string SelectedText
{
get
{
int length = SelectionLength;
return length > 0 ? _text.Substring(SelectionLeft, length) : string.Empty;
}
}
///
/// Gets the number of characters selected in the text box.
///
public int SelectionLength => Mathf.Abs(_selectionEnd - _selectionStart);
///
/// Gets or sets the selection range.
///
public TextRange SelectionRange
{
get => new TextRange(SelectionLeft, SelectionRight);
set => SetSelection(value.StartIndex, value.EndIndex, false);
}
///
/// Returns true if any text is selected, otherwise false
///
public bool HasSelection => SelectionLength > 0;
///
/// Index of the character on left edge of the selection
///
protected int SelectionLeft => Mathf.Min(_selectionStart, _selectionEnd);
///
/// Index of the character on right edge of the selection
///
protected int SelectionRight => Mathf.Max(_selectionStart, _selectionEnd);
///
/// Gets current caret position (index of the character)
///
protected int CaretPosition => _selectionEnd;
///
/// Calculates the caret rectangle.
///
protected Rectangle CaretBounds
{
get
{
const float caretWidth = 1.2f;
Vector2 caretPos = GetCharPosition(CaretPosition, out var height);
return new Rectangle(
caretPos.X - (caretWidth * 0.5f),
caretPos.Y,
caretWidth,
height);
}
}
///
/// Gets rectangle with area for text
///
protected virtual Rectangle TextRectangle => new Rectangle(DefaultMargin, 1, Width - 2 * DefaultMargin, Height - 2);
///
/// Gets rectangle used to clip text
///
protected virtual Rectangle TextClipRectangle => new Rectangle(1, 1, Width - 2, Height - 2);
///
/// Initializes a new instance of the class.
///
protected TextBoxBase()
: this(false, 0, 0)
{
}
///
/// Initializes a new instance of the class.
///
/// Enable/disable multiline text input support.
/// The control position X coordinate.
/// The control position Y coordinate.
/// The control width.
protected TextBoxBase(bool isMultiline, float x, float y, float width = 120)
: base(x, y, width, DefaultHeight)
{
_isMultiline = isMultiline;
_maxLength = 2147483646;
_selectionStart = _selectionEnd = -1;
AutoFocus = false;
var style = Style.Current;
CaretColor = style.Foreground;
BorderColor = Color.Transparent;
BorderSelectedColor = style.BackgroundSelected;
BackgroundColor = style.TextBoxBackground;
BackgroundSelectedColor = style.TextBoxBackgroundSelected;
}
///
/// Clears all text from the text box control.
///
public virtual void Clear()
{
Text = string.Empty;
}
///
/// Clear selection range
///
public virtual void ClearSelection()
{
OnSelectingEnd();
SetSelection(-1);
}
///
/// Resets the view offset (text scroll view).
///
public virtual void ResetViewOffset()
{
TargetViewOffset = Vector2.Zero;
}
///
/// Called when target view offset gets changed.
///
protected virtual void OnTargetViewOffsetChanged()
{
TargetViewOffsetChanged?.Invoke();
}
///
/// Copies the current selection in the text box to the Clipboard.
///
public virtual void Copy()
{
var selectedText = SelectedText;
if (selectedText.Length > 0)
{
// Copy selected text
Clipboard.Text = selectedText;
}
}
///
/// Moves the current selection in the text box to the Clipboard.
///
public virtual void Cut()
{
var selectedText = SelectedText;
if (selectedText.Length > 0)
{
// Copy selected text
Clipboard.Text = selectedText;
if (IsReadOnly)
return;
// Remove selection
int left = SelectionLeft;
_text = _text.Remove(left, SelectionLength);
SetSelection(left);
OnTextChanged();
}
}
///
/// Replaces the current selection in the text box with the contents of the Clipboard.
///
public virtual void Paste()
{
if (IsReadOnly)
return;
// Get clipboard data
var clipboardText = Clipboard.Text;
if (string.IsNullOrEmpty(clipboardText))
return;
var right = SelectionRight;
Insert(clipboardText);
SetSelection(Mathf.Max(right, 0) + clipboardText.Length);
}
///
/// Duplicates the current selection in the text box.
///
public virtual void Duplicate()
{
if (IsReadOnly)
return;
var selectedText = SelectedText;
if (selectedText.Length > 0)
{
var right = SelectionRight;
SetSelection(right);
Insert(selectedText);
SetSelection(right, right + selectedText.Length);
}
}
///
/// Ensures that the caret is visible in the TextBox window, by scrolling the TextBox control surface if necessary.
///
public virtual void ScrollToCaret()
{
// If it's empty
if (_text.Length == 0)
{
TargetViewOffset = Vector2.Zero;
return;
}
// If it's not selected
if (_selectionStart == -1 && _selectionEnd == -1)
{
return;
}
Rectangle caretBounds = CaretBounds;
Rectangle textArea = TextRectangle;
// Update view offset (caret needs to be in a view)
Vector2 caretInView = caretBounds.Location - _targetViewOffset;
Vector2 clampedCaretInView = Vector2.Clamp(caretInView, textArea.UpperLeft, textArea.BottomRight);
TargetViewOffset += caretInView - clampedCaretInView;
}
///
/// Selects all text in the text box.
///
public virtual void SelectAll()
{
if (TextLength > 0)
{
SetSelection(0, TextLength);
}
}
///
/// Sets the selection to empty value.
///
public virtual void Deselect()
{
SetSelection(-1);
}
///
/// Gets the character the index at point (eg. mouse location in control-space).
///
/// The location (in control-space).
/// The character index under the location
public virtual int CharIndexAtPoint(ref Vector2 location)
{
return HitTestText(location + _viewOffset);
}
///
/// Inserts the specified character (at the current selection).
///
/// The character.
public virtual void Insert(char c)
{
Insert(c.ToString());
}
///
/// Inserts the specified text (at the current selection).
///
/// The string.
public virtual void Insert(string str)
{
if (IsReadOnly)
return;
// Filter text
if (str.IndexOf('\r') != -1)
str = str.Replace("\r", "");
int selectionLength = SelectionLength;
int charactersLeft = MaxLength - _text.Length + selectionLength;
Assert.IsTrue(charactersLeft >= 0);
if (charactersLeft == 0)
return;
if (charactersLeft < str.Length)
str = str.Substring(0, charactersLeft);
if (TextLength == 0)
{
_text = str;
SetSelection(TextLength);
}
else
{
var left = SelectionLeft >= 0 ? SelectionLeft : 0;
if (HasSelection)
_text = _text.Remove(left, selectionLength);
_text = _text.Insert(left, str);
SetSelection(left + 1);
}
OnTextChanged();
}
///
/// Moves the caret right.
///
/// Shift is held.
/// Control is held.
protected virtual void MoveRight(bool shift, bool ctrl)
{
if (HasSelection && !shift)
{
SetSelection(SelectionRight);
}
else if (SelectionRight < TextLength)
{
int position;
if (ctrl)
position = FindNextWordBegin();
else
position = _selectionEnd + 1;
if (shift)
{
SetSelection(_selectionStart, position);
}
else
{
SetSelection(position);
}
}
}
///
/// Moves the caret left.
///
/// Shift is held.
/// Control is held.
protected virtual void MoveLeft(bool shift, bool ctrl)
{
if (HasSelection && !shift)
{
SetSelection(SelectionLeft);
}
else if (SelectionLeft > 0)
{
int position;
if (ctrl)
position = FindPrevWordBegin();
else
position = _selectionEnd - 1;
if (shift)
{
SetSelection(_selectionStart, position);
}
else
{
SetSelection(position);
}
}
}
///
/// Moves the caret down.
///
/// Shift is held.
/// Control is held.
protected virtual void MoveDown(bool shift, bool ctrl)
{
if (HasSelection && !shift)
{
SetSelection(SelectionRight);
}
else
{
int position = FindLineDownChar(CaretPosition);
if (shift)
{
SetSelection(_selectionStart, position);
}
else
{
SetSelection(position);
}
}
}
///
/// Moves the caret up.
///
/// Shift is held.
/// Control is held.
protected virtual void MoveUp(bool shift, bool ctrl)
{
if (HasSelection && !shift)
{
SetSelection(SelectionLeft);
}
else
{
int position = FindLineUpChar(CaretPosition);
if (shift)
{
SetSelection(_selectionStart, position);
}
else
{
SetSelection(position);
}
}
}
///
/// Sets the caret position.
///
/// The caret position.
/// If set to true with auto-scroll.
protected virtual void SetSelection(int caret, bool withScroll = true)
{
SetSelection(caret, caret);
}
///
/// Sets the selection.
///
/// The selection start character.
/// The selection end character.
/// If set to true with auto-scroll.
protected virtual void SetSelection(int start, int end, bool withScroll = true)
{
// Update parameters
int textLength = _text.Length;
_selectionStart = Mathf.Clamp(start, -1, textLength);
_selectionEnd = Mathf.Clamp(end, -1, textLength);
if (withScroll)
{
// Update view on caret modified
ScrollToCaret();
// Reset caret and selection animation
_animateTime = 0.0f;
}
}
private int FindNextWordBegin()
{
int textLength = TextLength;
int caretPos = CaretPosition;
if (caretPos + 1 >= textLength)
return textLength;
int spaceLoc = _text.IndexOfAny(Separators, caretPos + 1);
if (spaceLoc == -1)
spaceLoc = textLength;
else
spaceLoc++;
return spaceLoc;
}
private int FindPrevWordBegin()
{
int caretPos = CaretPosition;
if (caretPos - 2 < 0)
return 0;
int spaceLoc = _text.LastIndexOfAny(Separators, caretPos - 2);
if (spaceLoc == -1)
spaceLoc = 0;
else
spaceLoc++;
return spaceLoc;
}
private int FindPrevLineBegin()
{
int caretPos = CaretPosition;
if (caretPos - 2 < 0)
return 0;
int newLineLoc = _text.LastIndexOf('\n', caretPos - 2);
if (newLineLoc == -1)
newLineLoc = 0;
else
newLineLoc++;
return newLineLoc;
}
private int FindLineDownChar(int index)
{
if (!IsMultiline)
return 0;
Vector2 location = GetCharPosition(index, out var height);
location.Y += height;
return HitTestText(location);
}
private int FindLineUpChar(int index)
{
if (!IsMultiline)
return _text.Length;
Vector2 location = GetCharPosition(index, out var height);
location.Y -= height;
return HitTestText(location);
}
///
/// Calculates total text size. Called by to cache the text size.
///
/// The total text size.
public abstract Vector2 GetTextSize();
///
/// Calculates character position for given character index.
///
/// The text position to get it's coordinates.
/// The character height (at the given character position).
/// The character position (upper left corner which can be used for a caret position).
public abstract Vector2 GetCharPosition(int index, out float height);
///
/// Calculates hit character index at given location.
///
/// The location to test.
/// The selected character position index (can be equal to text length if location is outside of the layout rectangle).
public abstract int HitTestText(Vector2 location);
///
/// Called when is multiline gets changed.
///
protected virtual void OnIsMultilineChanged()
{
}
///
/// Called when is read only gets changed.
///
protected virtual void OnIsReadOnlyChanged()
{
}
///
/// Action called when user starts text selecting
///
protected virtual void OnSelectingBegin()
{
if (!_isSelecting)
{
// Set flag
_isSelecting = true;
// Start tracking mouse
StartMouseCapture();
}
}
///
/// Action called when user ends text selecting
///
protected virtual void OnSelectingEnd()
{
if (_isSelecting)
{
// Clear flag
_isSelecting = false;
// Stop tracking mouse
EndMouseCapture();
}
}
///
/// Action called when user starts text editing
///
protected virtual void OnEditBegin()
{
if (_isEditing)
return;
_isEditing = true;
_onStartEditValue = _text;
// Reset caret visibility
_animateTime = 0;
}
///
/// Action called when user ends text editing.
///
protected virtual void OnEditEnd()
{
if (!_isEditing)
return;
_isEditing = false;
if (_onStartEditValue != _text)
{
_onStartEditValue = _text;
EditEnd?.Invoke();
TextBoxEditEnd?.Invoke(this);
}
_onStartEditValue = string.Empty;
ClearSelection();
ResetViewOffset();
}
///
/// Action called when text gets modified.
///
protected virtual void OnTextChanged()
{
_textSize = GetTextSize();
TextChanged?.Invoke();
}
///
public override void Update(float deltaTime)
{
bool isDeltaSlow = deltaTime > (1 / 20.0f);
_animateTime += deltaTime;
// Animate view offset
_viewOffset = isDeltaSlow ? _targetViewOffset : Vector2.Lerp(_viewOffset, _targetViewOffset, deltaTime * 20.0f);
base.Update(deltaTime);
}
///
public override void OnGotFocus()
{
base.OnGotFocus();
if (IsReadOnly)
return;
OnEditBegin();
}
///
public override void OnLostFocus()
{
base.OnLostFocus();
if (IsReadOnly)
return;
OnEditEnd();
}
///
public override void OnEndMouseCapture()
{
// Clear flag
_isSelecting = false;
}
///
public override void OnMouseMove(Vector2 location)
{
// Check if user is selecting
if (_isSelecting)
{
// Find char index at current mouse location
int currentIndex = CharIndexAtPoint(ref location);
// Modify selection end
SetSelection(_selectionStart, currentIndex);
}
}
///
public override bool OnMouseDown(Vector2 location, MouseButton button)
{
if (button == MouseButton.Left && _text.Length > 0)
{
Focus();
OnSelectingBegin();
// Calculate char index under the mouse location
var hitPos = CharIndexAtPoint(ref location);
// Select range with shift
if (_selectionStart != -1 && RootWindow.GetKey(KeyboardKeys.Shift) && SelectionLength == 0)
{
if (hitPos < _selectionStart)
SetSelection(hitPos, _selectionStart);
else
SetSelection(_selectionStart, hitPos);
}
else
{
SetSelection(hitPos);
}
return true;
}
if (button == MouseButton.Left && !IsFocused)
{
Focus();
return true;
}
return false;
}
///
public override bool OnMouseUp(Vector2 location, MouseButton button)
{
if (button == MouseButton.Left)
{
OnSelectingEnd();
return true;
}
return false;
}
///
public override bool OnMouseWheel(Vector2 location, float delta)
{
if (base.OnMouseWheel(location, delta))
return true;
// Multiline scroll
if (IsMultiline && _text.Length != 0)
{
TargetViewOffset = Vector2.Clamp(_targetViewOffset - new Vector2(0, delta * 10.0f), Vector2.Zero, new Vector2(_targetViewOffset.X, _textSize.Y));
return true;
}
// No event handled
return false;
}
///
public override bool OnCharInput(char c)
{
Insert(c);
return true;
}
///
public override bool OnKeyDown(KeyboardKeys key)
{
var window = Root;
bool shiftDown = window.GetKey(KeyboardKeys.Shift);
bool ctrDown = window.GetKey(KeyboardKeys.Control);
switch (key)
{
case KeyboardKeys.ArrowRight:
MoveRight(shiftDown, ctrDown);
return true;
case KeyboardKeys.ArrowLeft:
MoveLeft(shiftDown, ctrDown);
return true;
case KeyboardKeys.ArrowUp:
MoveUp(shiftDown, ctrDown);
return true;
case KeyboardKeys.ArrowDown:
MoveDown(shiftDown, ctrDown);
return true;
case KeyboardKeys.C:
if (ctrDown)
{
Copy();
return true;
}
break;
case KeyboardKeys.V:
if (ctrDown)
{
Paste();
return true;
}
break;
case KeyboardKeys.D:
if (ctrDown)
{
Duplicate();
return true;
}
break;
case KeyboardKeys.X:
if (ctrDown)
{
Cut();
return true;
}
break;
case KeyboardKeys.A:
if (ctrDown)
{
SelectAll();
return true;
}
break;
case KeyboardKeys.Backspace:
{
if (IsReadOnly)
return true;
int left = SelectionLeft;
if (HasSelection)
{
_text = _text.Remove(left, SelectionLength);
SetSelection(left);
OnTextChanged();
}
else if (CaretPosition > 0)
{
left -= 1;
_text = _text.Remove(left, 1);
SetSelection(left);
OnTextChanged();
}
return true;
}
case KeyboardKeys.Delete:
{
if (IsReadOnly)
return true;
int left = SelectionLeft;
if (HasSelection)
{
_text = _text.Remove(left, SelectionLength);
SetSelection(left);
OnTextChanged();
}
else if (TextLength > 0 && left < TextLength)
{
_text = _text.Remove(left, 1);
SetSelection(left);
OnTextChanged();
}
return true;
}
case KeyboardKeys.Escape:
{
// Restore text from start
SetSelection(-1);
_text = _onStartEditValue;
Defocus();
OnTextChanged();
return true;
}
case KeyboardKeys.Return:
if (IsMultiline)
{
// Insert new line
Insert('\n');
}
else
{
// End editing
Defocus();
}
return true;
case KeyboardKeys.Home:
if (shiftDown)
{
// Select text from the current cursor point back to the beginning of the line
if (_selectionStart != -1)
{
SetSelection(FindPrevLineBegin(), _selectionStart);
}
}
else
{
// Move caret to the first character
SetSelection(0);
}
return true;
case KeyboardKeys.End:
{
// Move caret after last character
SetSelection(TextLength);
return true;
}
}
return false;
}
}
}