diff --git a/Robust.Client/Properties/AssemblyInfo.cs b/Robust.Client/Properties/AssemblyInfo.cs index 9bcd2978d..e1e38e672 100644 --- a/Robust.Client/Properties/AssemblyInfo.cs +++ b/Robust.Client/Properties/AssemblyInfo.cs @@ -2,6 +2,7 @@ [assembly: InternalsVisibleTo("Robust.UnitTesting")] [assembly: InternalsVisibleTo("Robust.Lite")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] #if NET5_0 [module: SkipLocalsInit] diff --git a/Robust.Client/UserInterface/Controls/LineEdit.cs b/Robust.Client/UserInterface/Controls/LineEdit.cs index be37c772b..bc8b56116 100644 --- a/Robust.Client/UserInterface/Controls/LineEdit.cs +++ b/Robust.Client/UserInterface/Controls/LineEdit.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text; using JetBrains.Annotations; using Robust.Client.Graphics; using Robust.Shared.Input; @@ -23,6 +24,7 @@ namespace Robust.Client.UserInterface.Controls 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 = ""; @@ -126,7 +128,11 @@ namespace Robust.Client.UserInterface.Controls get => _cursorPosition; set { - _cursorPosition = MathHelper.Clamp(value, 0, _text.Length); + 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; } } @@ -134,7 +140,14 @@ namespace Robust.Client.UserInterface.Controls public int SelectionStart { get => _selectionStart; - set => _selectionStart = MathHelper.Clamp(value, 0, _text.Length); + 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); @@ -275,7 +288,7 @@ namespace Robust.Client.UserInterface.Controls return; } - InsertAtCursor(((char) args.CodePoint).ToString()); + InsertAtCursor(args.AsRune.ToString()); } protected internal override void KeyBindDown(GUIBoundKeyEventArgs args) @@ -302,8 +315,16 @@ namespace Robust.Client.UserInterface.Controls } else if (_cursorPosition != 0) { - _text = _text.Remove(_cursorPosition - 1, 1); - _cursorPosition -= 1; + 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; } @@ -330,7 +351,10 @@ namespace Robust.Client.UserInterface.Controls } else if (_cursorPosition < _text.Length) { - _text = _text.Remove(_cursorPosition, 1); + var remAmt = 1; + if (char.IsHighSurrogate(_text[_cursorPosition])) + remAmt = 2; + _text = _text.Remove(_cursorPosition, remAmt); changed = true; } @@ -352,10 +376,7 @@ namespace Robust.Client.UserInterface.Controls } else { - if (_cursorPosition != 0) - { - _cursorPosition -= 1; - } + ShiftCursorLeft(); _selectionStart = _cursorPosition; } @@ -370,10 +391,7 @@ namespace Robust.Client.UserInterface.Controls } else { - if (_cursorPosition != _text.Length) - { - _cursorPosition += 1; - } + ShiftCursorRight(); _selectionStart = _cursorPosition; } @@ -404,19 +422,13 @@ namespace Robust.Client.UserInterface.Controls } else if (args.Function == EngineKeyFunctions.TextCursorSelectLeft) { - if (_cursorPosition != 0) - { - _cursorPosition -= 1; - } + ShiftCursorLeft(); args.Handle(); } else if (args.Function == EngineKeyFunctions.TextCursorSelectRight) { - if (_cursorPosition != _text.Length) - { - _cursorPosition += 1; - } + ShiftCursorRight(); args.Handle(); } @@ -525,6 +537,28 @@ namespace Robust.Client.UserInterface.Controls // 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) @@ -559,7 +593,7 @@ namespace Robust.Client.UserInterface.Controls { if (!font.TryGetCharMetrics(rune, UIScale, out var metrics)) { - index += 1; + index += rune.Utf16SequenceLength; continue; } @@ -570,7 +604,7 @@ namespace Robust.Client.UserInterface.Controls lastChrPostX = chrPosX; chrPosX += metrics.Advance; - index += 1; + index += rune.Utf16SequenceLength; if (chrPosX > contentBox.Right) { @@ -586,6 +620,9 @@ namespace Robust.Client.UserInterface.Controls if (index > 0 && distanceRight > distanceLeft) { index -= 1; + + if (char.IsLowSurrogate(_text[index])) + index -= 1; } return index; @@ -652,60 +689,87 @@ namespace Robust.Client.UserInterface.Controls } // Approach for NextWordPosition and PrevWordPosition taken from Avalonia. - private int NextWordPosition(string str, int cursor) + internal static int NextWordPosition(string str, int cursor) { if (cursor >= str.Length) { return str.Length; } - var charClass = GetCharClass(str[cursor]); + var charClass = GetCharClass(Rune.GetRuneAt(str, cursor)); var i = cursor; - for (; i < str.Length && GetCharClass(str[i]) == charClass; i++) - { - } - for (; i < str.Length && GetCharClass(str[i]) == CharClass.Whitespace; i++) - { - } + 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; + } + } } - private int PrevWordPosition(string str, int cursor) + internal static int PrevWordPosition(string str, int cursor) { if (cursor == 0) { return 0; } - var charClass = GetCharClass(str[cursor - 1]); + var startRune = GetRuneBackwards(str, cursor - 1); + var charClass = GetCharClass(startRune); var i = cursor; - for (; i > 0 && GetCharClass(str[i - 1]) == charClass; i--) - { - } + IterBackward(); if (charClass == CharClass.Whitespace) { - charClass = GetCharClass(str[i - 1]); - for (; i > 0 && GetCharClass(str[i - 1]) == charClass; i--) - { - } + 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); + } } - private CharClass GetCharClass(char chr) + internal static CharClass GetCharClass(Rune rune) { - if (char.IsWhiteSpace(chr)) + if (Rune.IsWhiteSpace(rune)) { return CharClass.Whitespace; } - if (char.IsLetterOrDigit(chr)) + if (Rune.IsLetterOrDigit(rune)) { return CharClass.AlphaNumeric; } @@ -713,7 +777,7 @@ namespace Robust.Client.UserInterface.Controls return CharClass.Other; } - private enum CharClass : byte + internal enum CharClass : byte { Other, AlphaNumeric, @@ -776,7 +840,7 @@ namespace Robust.Client.UserInterface.Controls } posX += metrics.Advance; - count += 1; + count += chr.Utf16SequenceLength; if (count == _master._cursorPosition) { diff --git a/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs b/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs new file mode 100644 index 000000000..b008af502 --- /dev/null +++ b/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs @@ -0,0 +1,185 @@ +using Moq; +using NUnit.Framework; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Input; +using Robust.Shared.IoC; + +namespace Robust.UnitTesting.Client.UserInterface.Controls +{ + [TestFixture] + [TestOf(typeof(LineEdit))] + public sealed class LineEditTest + { + [OneTimeSetUp] + public void Setup() + { + var uiMgr = new Mock(); + + IoCManager.InitThread(); + IoCManager.Clear(); + IoCManager.RegisterInstance(uiMgr.Object); + IoCManager.RegisterInstance(uiMgr.Object); + IoCManager.BuildGraph(); + } + + [Test] + public void TestRuneBackspace() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = lineEdit.Text.Length; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextBackspace, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo")); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(3)); + } + + [Test] + public void TestRuneDelete() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = 3; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextDelete, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo")); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(3)); + } + + [Test] + public void TestMoveLeft() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = lineEdit.Text.Length; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextCursorLeft, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo👏")); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(3)); + } + + [Test] + public void TestMoveRight() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = 3; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextCursorRight, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo👏")); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(5)); + } + + [Test] + public void TestMoveSelectLeft() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = lineEdit.Text.Length; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextCursorSelectLeft, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo👏")); + Assert.That(lineEdit.SelectionStart, Is.EqualTo(5)); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(3)); + } + + [Test] + public void TestMoveSelectRight() + { + var lineEdit = new TestLineEdit(); + + lineEdit.Text = "Foo👏"; + lineEdit.CursorPosition = 3; + + var eventArgs = new GUIBoundKeyEventArgs( + EngineKeyFunctions.TextCursorSelectRight, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + Assert.That(lineEdit.Text, Is.EqualTo("Foo👏")); + Assert.That(lineEdit.SelectionStart, Is.EqualTo(3)); + Assert.That(lineEdit.CursorPosition, Is.EqualTo(5)); + } + + [Test] + // RIGHT + [TestCase("Foo Bar Baz", false, 0, ExpectedResult = 4)] + [TestCase("Foo Bar Baz", false, 8, ExpectedResult = 11)] + [TestCase("Foo[Bar[Baz", false, 0, ExpectedResult = 3)] + [TestCase("Foo[Bar[Baz", false, 3, ExpectedResult = 4)] + [TestCase("Foo^Bar^Baz", false, 0, ExpectedResult = 3)] + [TestCase("Foo^Bar^Baz", false, 3, ExpectedResult = 5)] + [TestCase("Foo^^^Bar^Baz", false, 3, ExpectedResult = 9)] + [TestCase("^^^ ^^^", false, 0, ExpectedResult = 7)] + [TestCase("^^^ ^^^", false, 7, ExpectedResult = 13)] + // LEFT + [TestCase("Foo Bar Baz", true, 4, ExpectedResult = 0)] + [TestCase("Foo Bar Baz", true, 11, ExpectedResult = 8)] + [TestCase("Foo[Bar[Baz", true, 3, ExpectedResult = 0)] + [TestCase("Foo[Bar[Baz", true, 4, ExpectedResult = 3)] + [TestCase("Foo^Bar^Baz", true, 3, ExpectedResult = 0)] + [TestCase("Foo^Bar^Baz", true, 5, ExpectedResult = 3)] + [TestCase("Foo^^^Bar^Baz", true, 9, ExpectedResult = 3)] + [TestCase("^^^ ^^^", true, 7, ExpectedResult = 0)] + [TestCase("^^^ ^^^", true, 13, ExpectedResult = 7)] + public int TestMoveWord(string text, bool left, int initCursorPos) + { + // ^ is replaced by 👏 because Rider refuses to run the tests otherwise. + text = text.Replace("^", "👏"); + + var lineEdit = new TestLineEdit(); + + lineEdit.Text = text; + lineEdit.CursorPosition = initCursorPos; + + var eventArgs = new GUIBoundKeyEventArgs( + left ? EngineKeyFunctions.TextCursorWordLeft : EngineKeyFunctions.TextCursorWordRight, + BoundKeyState.Down, + default, false, default, default); + lineEdit.KeyBindDown(eventArgs); + + return lineEdit.CursorPosition; + } + + + private sealed class TestLineEdit : LineEdit + { + public override bool HasKeyboardFocus() + { + return true; + } + } + } +}