Handle surrogate pairs correctly in LineEdit.

This commit is contained in:
Pieter-Jan Briers
2021-03-10 16:55:12 +01:00
parent 3cfcfa0be2
commit 516b2cd372
3 changed files with 296 additions and 46 deletions

View File

@@ -2,6 +2,7 @@
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
[assembly: InternalsVisibleTo("Robust.Lite")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
#if NET5_0
[module: SkipLocalsInit]

View File

@@ -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)
{

View 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;
}
}
}
}