mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-14 19:29:36 +01:00
Handle surrogate pairs correctly in LineEdit.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
|
||||
[assembly: InternalsVisibleTo("Robust.Lite")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
|
||||
#if NET5_0
|
||||
[module: SkipLocalsInit]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
185
Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs
Normal file
185
Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs
Normal file
@@ -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<IUserInterfaceManagerInternal>();
|
||||
|
||||
IoCManager.InitThread();
|
||||
IoCManager.Clear();
|
||||
IoCManager.RegisterInstance<IUserInterfaceManagerInternal>(uiMgr.Object);
|
||||
IoCManager.RegisterInstance<IUserInterfaceManager>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user