Files
RobustToolbox/Robust.Client/UserInterface/Controls/LineEdit.cs
faint 9e2ab2a917 Double-clicking in LineEdit (#4831)
* Double-clicking in LineEdit

epic

* Fix IGameTiming dependency

* remove iocmanager.resolves

* test fix

* Update Robust.Client/UserInterface/Controls/LineEdit.cs

Co-authored-by: ShadowCommander <shadowjjt@gmail.com>

* review

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: ShadowCommander <shadowjjt@gmail.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
2024-03-31 15:25:31 +11:00

1154 lines
40 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Shared;
using Robust.Shared.Configuration;
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>
[Virtual]
public class LineEdit : Control
{
[Dependency] private readonly IClyde _clyde = default!;
[Dependency] private readonly IConfigurationManager _cfgManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private const float MouseScrollDelay = 0.001f;
public const string StylePropertyStyleBox = "stylebox";
public const string StylePropertyCursorColor = "cursor-color";
public const string StylePropertySelectionColor = "selection-color";
public const string StyleClassLineEditNotEditable = "notEditable";
public const string StylePseudoClassPlaceholder = "placeholder";
public StyleBox? StyleBoxOverride { get; set; }
// 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 TextEditShared.CursorBlink _blink;
private readonly LineEditRenderBox _renderBox;
private bool _mouseSelectingText;
private float _lastMousePosition;
private TimeSpan? _lastClickTime;
private Vector2? _lastClickPosition;
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;
public event Action<LineEditEventArgs>? OnTabComplete;
/// <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. Setting this does not invoke the text change event, use <see cref="SetText(string, bool)"/> for that.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
public string Text
{
get => _text;
set => SetText(value);
}
/// <summary>
/// Sets the current text, optionally invoking <see cref="OnTextChanged"/>.
/// </summary>
public void SetText(string value, bool invokeEvent = false)
{
if (value == _text)
return;
// Save cursor position or -1 for end
var cursorTarget = CursorPosition == _text.Length ? -1 : CursorPosition;
if (!InternalSetText(value))
{
return;
}
var clamped = MathHelper.Clamp(cursorTarget == -1 ? _text.Length : cursorTarget, 0, _text.Length);
while (clamped < _text.Length && !Rune.TryGetRuneAt(_text, clamped, out _))
{
clamped++;
}
_cursorPosition = clamped;
_selectionStart = _cursorPosition;
_updatePseudoClass();
if (invokeEvent)
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
}
public void ForceSubmitText()
{
OnTextEntered?.Invoke(new LineEditEventArgs(this, _text));
}
/// <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; }
private (int start, int length)? _imeData;
// 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.
// Fifth future me is unsure what this is even about but continues to be grateful.
public LineEdit()
{
IoCManager.InjectDependencies(this);
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 (!InternalSetText(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>>
private bool InternalSetText(string newText)
{
if (IsValid != null && !IsValid(newText))
{
return false;
}
_text = newText;
return true;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
IgnoreNext = false;
_blink.FrameUpdate(args);
if (_mouseSelectingText)
{
var style = _getStyleBox();
var contentBox = style.GetContentBox(PixelSizeBox, UIScale);
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(1.0f)) + style.MinimumSize;
}
protected override Vector2 ArrangeOverride(Vector2 finalSize)
{
var style = _getStyleBox();
var box = UIBox2.FromDimensions(Vector2.Zero, finalSize);
_renderBox.Arrange(style.GetContentBox(box, 1));
return finalSize;
}
public event Action<GUITextEnteredEventArgs>? OnTextTyped;
protected internal override void TextEntered(GUITextEnteredEventArgs args)
{
base.TextEntered(args);
if (!Editable)
{
return;
}
if (IgnoreNext)
{
IgnoreNext = false;
return;
}
InsertAtCursor(args.Text);
OnTextTyped?.Invoke(args);
}
protected internal override void TextEditing(GUITextEditingEventArgs args)
{
base.TextEditing(args);
if (!Editable)
return;
// TODO: yeah so uh this ignores all the valid checks and everything like that.
// Uh....
var ev = args.Event;
var startChars = ev.GetStartChars();
// Just break an active composition and build it anew to handle in-progress ones.
AbortIme();
if (ev.Text != "")
{
if (_selectionStart != _cursorPosition)
{
// Delete active text selection.
InsertAtCursor("");
}
var startPos = _cursorPosition;
_text = _text[..startPos] + ev.Text + _text[startPos..];
_selectionStart = _cursorPosition = startPos + startChars;
_imeData = (startPos, ev.Text.Length);
_updatePseudoClass();
}
}
private void AbortIme(bool delete = true)
{
if (!_imeData.HasValue)
return;
if (delete)
{
var (imeStart, imeLength) = _imeData.Value;
_text = _text.Remove(imeStart, imeLength);
_selectionStart = _cursorPosition = imeStart;
_updatePseudoClass();
}
_imeData = null;
}
public event Action<LineEditTextRemovedEventArgs>? OnTextRemoved;
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;
var oldText = _text;
var cursor = _cursorPosition;
var selectStart = _selectionStart;
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();
OnTextRemoved?.Invoke(new LineEditTextRemovedEventArgs(oldText, _text, cursor, selectStart));
}
}
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();
OnTextRemoved?.Invoke(new LineEditTextRemovedEventArgs(_text, _text, _cursorPosition, _selectionStart));
}
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextWordBackspace)
{
if (Editable)
{
var changed = false;
// If there is a selection, we just delete the selection. Otherwise we delete the previous word
if (_selectionStart != _cursorPosition)
{
_text = _text.Remove(SelectionLower, SelectionLength);
_cursorPosition = SelectionLower;
changed = true;
}
else if (_cursorPosition != 0)
{
int remAmt = _cursorPosition - TextEditShared.PrevWordPosition(_text, _cursorPosition);
_text = _text.Remove(_cursorPosition - remAmt, remAmt);
_cursorPosition -= remAmt;
changed = true;
}
if (changed)
{
_selectionStart = _cursorPosition;
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
_updatePseudoClass();
OnTextRemoved?.Invoke(new LineEditTextRemovedEventArgs(_text, _text, _cursorPosition, _selectionStart));
}
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextWordDelete)
{
if (Editable)
{
var changed = false;
// If there is a selection, we just delete the selection. Otherwise we delete the next word
if (_selectionStart != _cursorPosition)
{
_text = _text.Remove(SelectionLower, SelectionLength);
_cursorPosition = SelectionLower;
changed = true;
}
else if (_cursorPosition < _text.Length)
{
int nextWord = TextEditShared.EndWordPosition(_text, _cursorPosition);
_text = _text.Remove(_cursorPosition, nextWord - _cursorPosition);
changed = true;
}
if (changed)
{
_selectionStart = _cursorPosition;
OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
_updatePseudoClass();
OnTextRemoved?.Invoke(new LineEditTextRemovedEventArgs(_text, _text, _cursorPosition, _selectionStart));
}
}
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 = TextEditShared.PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorWordRight)
{
_selectionStart = _cursorPosition = TextEditShared.EndWordPosition(_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 = TextEditShared.PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectWordRight)
{
_cursorPosition = TextEditShared.EndWordPosition(_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)
{
ForceSubmitText();
}
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextPaste)
{
if (Editable)
{
async void DoPaste()
{
var clipboard = IoCManager.Resolve<IClipboardManager>();
var text = await clipboard.GetText();
if (text != null)
{
InsertAtCursor(text);
}
}
DoPaste();
}
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 if (args.Function == EngineKeyFunctions.TextTabComplete)
{
if (Editable)
{
OnTabComplete?.Invoke(new LineEditEventArgs(this, _text));
}
args.Handle();
}
}
// Double-clicking. Clicks delay should be <= 250ms and the distance < 10 pixels.
else if (args.Function == EngineKeyFunctions.UIClick && _lastClickPosition != null && _lastClickTime != null
&& _timing.RealTime - _lastClickTime <= TimeSpan.FromMilliseconds(_cfgManager.GetCVar(CVars.DoubleClickDelay))
&& (_lastClickPosition.Value - args.PointerLocation.Position).IsShorterThan(_cfgManager.GetCVar(CVars.DoubleClickRange)))
{
_lastClickTime = _timing.RealTime;
_lastClickPosition = args.PointerLocation.Position;
_lastMousePosition = args.RelativePosition.X;
_selectionStart = TextEditShared.PrevWordPosition(_text, GetIndexAtPos(args.RelativePosition.X));
_cursorPosition = TextEditShared.EndWordPosition(_text, GetIndexAtPos(args.RelativePosition.X));
args.Handle();
}
else
{
_lastClickTime = _timing.RealTime;
_lastClickPosition = args.PointerLocation.Position;
_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.
_blink.Reset();
void ShiftCursorLeft()
{
if (_cursorPosition == 0)
return;
_cursorPosition -= 1;
if (char.IsLowSurrogate(_text[_cursorPosition]))
_cursorPosition -= 1;
}
void ShiftCursorRight()
{
if (_cursorPosition == _text.Length)
return;
_cursorPosition += 1;
// Before you confuse yourself on "shouldn't this be high surrogate since shifting left checks low"
// (Because yes, I did myself too a week after writing it)
// char.IsLowSurrogate(_text[_cursorPosition]) means "is the cursor between a surrogate pair"
// because we ALREADY moved.
if (_cursorPosition != _text.Length && 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, UIScale);
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;
}
/// <summary>
/// Get offset from the left of the control
/// to the left edge of the text glyph at the specified index in the text.
/// </summary>
/// <remarks>
/// The returned value can be outside the bounds of the control if the glyph is currently clipped off.
/// </remarks>
public float GetOffsetAtIndex(int index)
{
var style = _getStyleBox();
var contentBox = style.GetContentBox(PixelSizeBox, UIScale);
var font = _getFont();
var i = 0;
var chrPosX = contentBox.Left - _drawOffset;
foreach (var rune in _text.EnumerateRunes())
{
if (i >= index)
break;
if (font.TryGetCharMetrics(rune, UIScale, out var metrics))
chrPosX += metrics.Advance;
i += rune.Utf16SequenceLength;
}
return chrPosX / UIScale;
}
protected internal override void KeyboardFocusEntered()
{
base.KeyboardFocusEntered();
// Reset this so the cursor is always visible immediately after gaining focus..
_blink.Reset();
OnFocusEnter?.Invoke(new LineEditEventArgs(this, _text));
if (Editable)
{
_clyde.TextInputStart();
}
}
protected internal override void KeyboardFocusExited()
{
base.KeyboardFocusExited();
OnFocusExit?.Invoke(new LineEditEventArgs(this, _text));
_clyde.TextInputStop();
AbortIme(delete: false);
}
[Pure]
private Font _getFont()
{
if (TryGetStyleProperty<Font>("font", out var font))
{
return font;
}
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
[Pure]
private StyleBox _getStyleBox()
{
if (StyleBoxOverride != null)
{
return StyleBoxOverride;
}
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, UIScale);
}
public sealed class LineEditEventArgs : EventArgs
{
public LineEdit Control { get; }
public string Text { get; }
public LineEditEventArgs(LineEdit control, string text)
{
Control = control;
Text = text;
}
}
public sealed class LineEditTextRemovedEventArgs : EventArgs
{
public string OldText { get; }
public string NewText { get; }
public int OldCursorPosition { get; }
public int OldSelectionStart { get; }
public LineEditTextRemovedEventArgs(
string oldText,
string newText,
int oldCursorPosition,
int oldSelectionStart)
{
OldText = oldText;
NewText = newText;
OldCursorPosition = oldCursorPosition;
OldSelectionStart = oldSelectionStart;
}
}
/// <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 uiScale = UIScale;
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;
var actualImeStartPosition = 0;
var actualImeEndPosition = 0;
var imeStartIndex = -1;
var imeEndIndex = -1;
if (_master._imeData.HasValue)
{
(imeStartIndex, var length) = _master._imeData.Value;
imeEndIndex = imeStartIndex + length;
}
foreach (var chr in renderedText.EnumerateRunes())
{
if (!font.TryGetCharMetrics(chr, uiScale, out var metrics))
{
count += 1;
continue;
}
posX += metrics.Advance;
count += chr.Utf16SequenceLength;
// NOTE: Due to the way this code works, these if statements don't get triggered
// if the relevant positions are all the way at the left of the LineEdit.
// This happens to be fine, because in that case the horizontal position = 0,
// and that's what we initialize the variables to by default.
if (count == _master._cursorPosition)
{
actualCursorPosition = posX;
}
if (count == _master._selectionStart)
{
actualSelectionStartPosition = posX;
}
if (count == imeStartIndex)
{
actualImeStartPosition = posX;
}
if (count == imeEndIndex)
{
actualImeEndPosition = 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)
{
var color = _master.StylePropertyDefault(
StylePropertySelectionColor,
Color.CornflowerBlue.WithAlpha(0.25f));
handle.DrawRect(
new UIBox2(selectionLower, contentBox.Top, selectionUpper, contentBox.Bottom),
color);
}
var cursorColor = _master.StylePropertyDefault(
StylePropertyCursorColor,
Color.White);
cursorColor.A *= _master._blink.Opacity;
handle.DrawRect(
new UIBox2(actualCursorPosition, contentBox.Top, actualCursorPosition + 1,
contentBox.Bottom),
cursorColor);
{
// Update IME position.
var imeBox = new UIBox2(
actualCursorPosition,
contentBox.Top,
contentBox.Right,
contentBox.Bottom);
_master._clyde.TextInputSetRect((UIBox2i) imeBox.Translated(GlobalPixelPosition));
}
}
// Draw IME underline if necessary.
if (_master._imeData.HasValue)
{
var y = baseLine.Y + font.GetDescent(uiScale);
var rect = new UIBox2(
actualImeStartPosition,
y - 1,
actualImeEndPosition,
y
);
handle.DrawRect(rect, renderedTextColor);
}
}
}
}
}