Fixes #3966. TextField crashes app when pasting unicode surrogate pair (#3982)

* Add constructor Key(int) and operator for handled with non-Bmp.

* Fix TextField non-BMP issues

* Fix TextField PositionCursor.

* Reformat

* Add IsValidInput method to handle clipboard paste when pressing CTRL+V in WT

* Add handle IsValidInput in FakeDriver and unit tests

* Fixes #3984 - `Margin` w/out shadow should not force draw (#3985)

* shortcut tests

* Generic demos

* Optimize Margin to not defer draw if there's no shadow

* Fixes #4041. WSLClipboard doesn't handles well with surrogate pairs

* Avoid running Clipboard.Contents twice

* Fixes #4042. Microsoft.VisualStudio.TestPlatform.ObjectModel.TestPlatformException: Could not find testhost

* Moving tests to the parallelizable unit tests

* Remove unused folder

* Prevent warnings about not installed nuget packages

* Using Toplevel instead of Application.Top

* Cleanup code

---------

Co-authored-by: Tig <tig@users.noreply.github.com>
This commit is contained in:
BDisp
2025-04-24 18:00:30 +01:00
committed by GitHub
parent 47bcf1bf57
commit 06e0ed0f29
16 changed files with 831 additions and 599 deletions

View File

@@ -52,7 +52,8 @@ public class KeyTests
{ "Ctrl-A", Key.A.WithCtrl },
{ "Alt-A", Key.A.WithAlt },
{ "A-Ctrl", Key.A.WithCtrl },
{ "Alt-A-Ctrl", Key.A.WithCtrl.WithAlt }
{ "Alt-A-Ctrl", Key.A.WithCtrl.WithAlt },
{ "📄", (KeyCode)0x1F4C4 }
};
[Theory]
@@ -120,10 +121,13 @@ public class KeyTests
[InlineData ('\'', (KeyCode)'\'')]
[InlineData ('\xFFFF', (KeyCode)0xFFFF)]
[InlineData ('\x0', (KeyCode)0x0)]
public void Cast_Char_To_Key (char ch, KeyCode expectedKeyCode)
public void Cast_Char_Int_To_Key (char ch, KeyCode expectedKeyCode)
{
var key = (Key)ch;
Assert.Equal (expectedKeyCode, key.KeyCode);
key = (int)ch;
Assert.Equal (expectedKeyCode, key.KeyCode);
}
[Fact]
@@ -140,23 +144,25 @@ public class KeyTests
[InlineData (KeyCode.A | KeyCode.ShiftMask, KeyCode.A | KeyCode.ShiftMask)]
[InlineData (KeyCode.Z, KeyCode.Z)]
[InlineData (KeyCode.Space, KeyCode.Space)]
public void Cast_KeyCode_To_Key (KeyCode cdk, KeyCode expected)
public void Cast_KeyCode_Int_To_Key (KeyCode cdk, KeyCode expected)
{
// explicit
// KeyCode
var key = (Key)cdk;
Assert.Equal (((Key)expected).ToString (), key.ToString ());
// implicit
key = cdk;
// Int
key = key.AsRune.Value;
Assert.Equal (((Key)expected).ToString (), key.ToString ());
}
// string cast operators
[Fact]
public void Cast_String_To_Key ()
[Theory]
[InlineData ("Ctrl+Q", KeyCode.Q | KeyCode.CtrlMask)]
[InlineData ("📄", (KeyCode)0x1F4C4)]
public void Cast_String_To_Key (string str, KeyCode expectedKeyCode)
{
var key = (Key)"Ctrl+Q";
Assert.Equal (KeyCode.Q | KeyCode.CtrlMask, key.KeyCode);
var key = (Key)str;
Assert.Equal (expectedKeyCode, key.KeyCode);
}
[Theory]
@@ -190,12 +196,37 @@ public class KeyTests
[InlineData ('\'', (KeyCode)'\'')]
[InlineData ('\xFFFF', (KeyCode)0xFFFF)]
[InlineData ('\x0', (KeyCode)0x0)]
public void Constructor_Char (char ch, KeyCode expectedKeyCode)
public void Constructor_Char_Int (char ch, KeyCode expectedKeyCode)
{
var key = new Key (ch);
Assert.Equal (expectedKeyCode, key.KeyCode);
key = new ((int)ch);
Assert.Equal (expectedKeyCode, key.KeyCode);
}
[Theory]
[InlineData (0x1F4C4, (KeyCode)0x1F4C4, "📄")]
[InlineData (0x1F64B, (KeyCode)0x1F64B, "🙋")]
[InlineData (0x1F46A, (KeyCode)0x1F46A, "👪")]
public void Constructor_Int_Non_Bmp (int value, KeyCode expectedKeyCode, string expectedString)
{
var key = new Key (value);
Assert.Equal (expectedKeyCode, key.KeyCode);
Assert.Equal (expectedString, key.AsRune.ToString ());
Assert.Equal (expectedString, key.ToString ());
}
[Theory]
[InlineData (-1)]
[InlineData (0x11FFFF)]
public void Constructor_Int_Invalid_Throws (int keyInt) { Assert.Throws<ArgumentOutOfRangeException> (() => new Key (keyInt)); }
[Theory]
[InlineData ('\ud83d')]
[InlineData ('\udcc4')]
public void Constructor_Int_Surrogate_Throws (int keyInt) { Assert.Throws<ArgumentException> (() => new Key (keyInt)); }
[Fact]
public void Constructor_Default_ShouldSetKeyToNull ()
{

View File

@@ -0,0 +1,554 @@
using System.Text;
namespace Terminal.Gui.ViewsTests;
public class TextFieldTests
{
[Fact]
public void Cancel_TextChanging_ThenBackspace ()
{
var tf = new TextField ();
tf.SetFocus ();
tf.NewKeyDownEvent (Key.A.WithShift);
Assert.Equal ("A", tf.Text);
// cancel the next keystroke
tf.TextChanging += (s, e) => e.Cancel = e.NewValue == "AB";
tf.NewKeyDownEvent (Key.B.WithShift);
// B was canceled so should just be A
Assert.Equal ("A", tf.Text);
// now delete the A
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("", tf.Text);
}
[Fact]
public void HistoryText_IsDirty_ClearHistoryChanges ()
{
var text = "Testing";
var tf = new TextField { Text = text };
tf.BeginInit ();
tf.EndInit ();
Assert.Equal (text, tf.Text);
tf.ClearHistoryChanges ();
Assert.False (tf.IsDirty);
Assert.True (tf.NewKeyDownEvent (Key.A.WithShift));
Assert.Equal ($"{text}A", tf.Text);
Assert.True (tf.IsDirty);
}
[Fact]
public void Space_Does_Not_Raise_Selected ()
{
TextField tf = new ();
tf.Selecting += (sender, args) => Assert.Fail ("Selected should not be raied.");
Toplevel top = new ();
top.Add (tf);
tf.SetFocus ();
top.NewKeyDownEvent (Key.Space);
top.Dispose ();
}
[Fact]
public void Enter_Does_Not_Raise_Selected ()
{
TextField tf = new ();
var selectingCount = 0;
tf.Selecting += (sender, args) => selectingCount++;
Toplevel top = new ();
top.Add (tf);
tf.SetFocus ();
top.NewKeyDownEvent (Key.Enter);
Assert.Equal (0, selectingCount);
top.Dispose ();
}
[Fact]
public void Enter_Raises_Accepted ()
{
TextField tf = new ();
var acceptedCount = 0;
tf.Accepting += (sender, args) => acceptedCount++;
Toplevel top = new ();
top.Add (tf);
tf.SetFocus ();
top.NewKeyDownEvent (Key.Enter);
Assert.Equal (1, acceptedCount);
top.Dispose ();
}
[Fact]
public void HotKey_Command_SetsFocus ()
{
var view = new TextField ();
view.CanFocus = true;
Assert.False (view.HasFocus);
view.InvokeCommand (Command.HotKey);
Assert.True (view.HasFocus);
}
[Fact]
public void HotKey_Command_Does_Not_Accept ()
{
var view = new TextField ();
var accepted = false;
view.Accepting += OnAccept;
view.InvokeCommand (Command.HotKey);
Assert.False (accepted);
return;
void OnAccept (object sender, CommandEventArgs e) { accepted = true; }
}
[Fact]
public void Accepted_Command_Fires_Accept ()
{
var view = new TextField ();
var accepted = false;
view.Accepting += Accept;
view.InvokeCommand (Command.Accept);
Assert.True (accepted);
return;
void Accept (object sender, CommandEventArgs e) { accepted = true; }
}
[Fact]
public void Accepted_No_Handler_Enables_Default_Button_Accept ()
{
var superView = new Window
{
Id = "superView"
};
var tf = new TextField
{
Id = "tf"
};
var button = new Button
{
Id = "button",
IsDefault = true
};
superView.Add (tf, button);
var buttonAccept = 0;
button.Accepting += ButtonAccept;
tf.SetFocus ();
Assert.True (tf.HasFocus);
superView.NewKeyDownEvent (Key.Enter);
Assert.Equal (1, buttonAccept);
button.SetFocus ();
superView.NewKeyDownEvent (Key.Enter);
Assert.Equal (2, buttonAccept);
return;
void ButtonAccept (object sender, CommandEventArgs e) { buttonAccept++; }
}
[Fact]
public void Accepted_Cancel_Event_HandlesCommand ()
{
//var super = new View ();
var view = new TextField ();
//super.Add (view);
//var superAcceptedInvoked = false;
var tfAcceptedInvoked = false;
var handle = false;
view.Accepting += TextViewAccept;
Assert.False (view.InvokeCommand (Command.Accept));
Assert.True (tfAcceptedInvoked);
tfAcceptedInvoked = false;
handle = true;
view.Accepting += TextViewAccept;
Assert.True (view.InvokeCommand (Command.Accept));
Assert.True (tfAcceptedInvoked);
return;
void TextViewAccept (object sender, CommandEventArgs e)
{
tfAcceptedInvoked = true;
e.Cancel = handle;
}
}
[Fact]
public void OnEnter_Does_Not_Throw_If_Not_IsInitialized_SetCursorVisibility ()
{
var top = new Toplevel ();
var tf = new TextField { Width = 10 };
top.Add (tf);
Exception exception = Record.Exception (() => tf.SetFocus ());
Assert.Null (exception);
}
[Fact]
public void Backspace_From_End ()
{
var tf = new TextField { Text = "ABC" };
tf.SetFocus ();
Assert.Equal ("ABC", tf.Text);
tf.BeginInit ();
tf.EndInit ();
Assert.Equal (3, tf.CursorPosition);
// now delete the C
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("AB", tf.Text);
Assert.Equal (2, tf.CursorPosition);
// then delete the B
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("A", tf.Text);
Assert.Equal (1, tf.CursorPosition);
// then delete the A
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("", tf.Text);
Assert.Equal (0, tf.CursorPosition);
}
[Fact]
public void Backspace_From_Middle ()
{
var tf = new TextField { Text = "ABC" };
tf.SetFocus ();
tf.CursorPosition = 2;
Assert.Equal ("ABC", tf.Text);
// now delete the B
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("AC", tf.Text);
// then delete the A
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("C", tf.Text);
// then delete nothing
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("C", tf.Text);
// now delete the C
tf.CursorPosition = 1;
tf.NewKeyDownEvent (Key.Backspace);
Assert.Equal ("", tf.Text);
}
[Fact]
public void KeyDown_Handled_Prevents_Input ()
{
var tf = new TextField ();
tf.KeyDown += HandleJKey;
tf.NewKeyDownEvent (Key.A);
Assert.Equal ("a", tf.Text);
// SuppressKey suppresses the 'j' key
tf.NewKeyDownEvent (Key.J);
Assert.Equal ("a", tf.Text);
tf.KeyDown -= HandleJKey;
// Now that the delegate has been removed we can type j again
tf.NewKeyDownEvent (Key.J);
Assert.Equal ("aj", tf.Text);
return;
void HandleJKey (object s, Key arg)
{
if (arg.AsRune == new Rune ('j'))
{
arg.Handled = true;
}
}
}
[InlineData ("a")] // Lower than selection
[InlineData ("aaaaaaaaaaa")] // Greater than selection
[InlineData ("aaaa")] // Equal than selection
[Theory]
public void SetTextAndMoveCursorToEnd_WhenExistingSelection (string newText)
{
var tf = new TextField ();
tf.Text = "fish";
tf.CursorPosition = tf.Text.Length;
tf.NewKeyDownEvent (Key.CursorLeft);
tf.NewKeyDownEvent (Key.CursorLeft.WithShift);
tf.NewKeyDownEvent (Key.CursorLeft.WithShift);
Assert.Equal (1, tf.CursorPosition);
Assert.Equal (2, tf.SelectedLength);
Assert.Equal ("is", tf.SelectedText);
tf.Text = newText;
tf.CursorPosition = tf.Text.Length;
Assert.Equal (newText.Length, tf.CursorPosition);
Assert.Equal (0, tf.SelectedLength);
Assert.Null (tf.SelectedText);
}
[Fact]
public void SpaceHandling ()
{
var tf = new TextField { Width = 10, Text = " " };
var ev = new MouseEventArgs { Position = new (0, 0), Flags = MouseFlags.Button1DoubleClicked };
tf.NewMouseEvent (ev);
Assert.Equal (1, tf.SelectedLength);
ev = new () { Position = new (1, 0), Flags = MouseFlags.Button1DoubleClicked };
tf.NewMouseEvent (ev);
Assert.Equal (1, tf.SelectedLength);
}
[Fact]
public void WordBackward_WordForward_Mixed ()
{
var tf = new TextField { Width = 30, Text = "Test with0. and!.?;-@+" };
tf.BeginInit ();
tf.EndInit ();
tf.NewKeyDownEvent (Key.CursorLeft.WithCtrl);
Assert.Equal (15, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorLeft.WithCtrl);
Assert.Equal (12, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorLeft.WithCtrl);
Assert.Equal (10, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorLeft.WithCtrl);
Assert.Equal (5, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorLeft.WithCtrl);
Assert.Equal (0, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorRight.WithCtrl);
Assert.Equal (5, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorRight.WithCtrl);
Assert.Equal (10, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorRight.WithCtrl);
Assert.Equal (12, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorRight.WithCtrl);
Assert.Equal (15, tf.CursorPosition);
tf.NewKeyDownEvent (Key.CursorRight.WithCtrl);
Assert.Equal (22, tf.CursorPosition);
}
[Fact]
public void WordBackward_WordForward_SelectedText_With_Accent ()
{
var text = "Les Misérables movie.";
var tf = new TextField { Width = 30, Text = text };
Assert.Equal (21, text.Length);
Assert.Equal (21, tf.Text.GetRuneCount ());
Assert.Equal (21, tf.Text.GetColumns ());
List<Rune> runes = tf.Text.ToRuneList ();
Assert.Equal (21, runes.Count);
Assert.Equal (21, tf.Text.Length);
for (var i = 0; i < runes.Count; i++)
{
char cs = text [i];
var cus = (char)runes [i].Value;
Assert.Equal (cs, cus);
}
var idx = 15;
Assert.Equal ('m', text [idx]);
Assert.Equal ('m', (char)runes [idx].Value);
Assert.Equal ("m", runes [idx].ToString ());
Assert.True (
tf.NewMouseEvent (
new () { Position = new (idx, 1), Flags = MouseFlags.Button1DoubleClicked, View = tf }
)
);
Assert.Equal ("movie.", tf.SelectedText);
Assert.True (
tf.NewMouseEvent (
new () { Position = new (idx + 1, 1), Flags = MouseFlags.Button1DoubleClicked, View = tf }
)
);
Assert.Equal ("movie.", tf.SelectedText);
}
[Fact]
public void Autocomplete_Popup_Added_To_SuperView_On_Init ()
{
View superView = new ()
{
CanFocus = true
};
TextField t = new ();
superView.Add (t);
Assert.Single (superView.SubViews);
superView.BeginInit ();
superView.EndInit ();
Assert.Equal (2, superView.SubViews.Count);
}
[Fact]
public void Autocomplete__Added_To_SuperView_On_Add ()
{
View superView = new ()
{
CanFocus = true,
Id = "superView"
};
superView.BeginInit ();
superView.EndInit ();
Assert.Empty (superView.SubViews);
TextField t = new ()
{
Id = "t"
};
superView.Add (t);
Assert.Equal (2, superView.SubViews.Count);
}
[Fact]
public void Right_CursorAtEnd_WithSelection_ShouldClearSelection ()
{
var tf = new TextField
{
Text = "Hello"
};
tf.SetFocus ();
tf.SelectAll ();
tf.CursorPosition = 5;
// When there is selected text and the cursor is at the end of the text field
Assert.Equal ("Hello", tf.SelectedText);
// Pressing right should not move focus, instead it should clear selection
Assert.True (tf.NewKeyDownEvent (Key.CursorRight));
Assert.Null (tf.SelectedText);
// Now that the selection is cleared another right keypress should move focus
Assert.False (tf.NewKeyDownEvent (Key.CursorRight));
}
[Fact]
public void Left_CursorAtStart_WithSelection_ShouldClearSelection ()
{
var tf = new TextField
{
Text = "Hello"
};
tf.SetFocus ();
tf.CursorPosition = 2;
Assert.True (tf.NewKeyDownEvent (Key.CursorLeft.WithShift));
Assert.True (tf.NewKeyDownEvent (Key.CursorLeft.WithShift));
// When there is selected text and the cursor is at the start of the text field
Assert.Equal ("He", tf.SelectedText);
// Pressing left should not move focus, instead it should clear selection
Assert.True (tf.NewKeyDownEvent (Key.CursorLeft));
Assert.Null (tf.SelectedText);
// When clearing selected text with left the cursor should be at the start of the selection
Assert.Equal (0, tf.CursorPosition);
// Now that the selection is cleared another left keypress should move focus
Assert.False (tf.NewKeyDownEvent (Key.CursorLeft));
}
[Fact]
public void Autocomplete_Visible_False_By_Default ()
{
View superView = new ()
{
CanFocus = true
};
TextField t = new ();
superView.Add (t);
superView.BeginInit ();
superView.EndInit ();
Assert.Equal (2, superView.SubViews.Count);
Assert.True (t.Visible);
Assert.False (t.Autocomplete.Visible);
}
[Fact]
public void InsertText_Bmp_SurrogatePair_Non_Bmp_Invalid_SurrogatePair ()
{
var tf = new TextField ();
//📄 == \ud83d\udcc4 == \U0001F4C4
// <20> == Rune.ReplacementChar
tf.InsertText ("aA,;\ud83d\udcc4\U0001F4C4\udcc4\ud83d");
Assert.Equal ("aA,;📄📄<F09F9384><F09F9384>", tf.Text);
}
[Fact]
public void PositionCursor_Respect_GetColumns ()
{
var tf = new TextField { Width = 5 };
tf.BeginInit ();
tf.EndInit ();
tf.NewKeyDownEvent (new ("📄"));
Assert.Equal (1, tf.CursorPosition);
Assert.Equal (new (2, 0), tf.PositionCursor ());
Assert.Equal ("📄", tf.Text);
tf.NewKeyDownEvent (new (KeyCode.A));
Assert.Equal (2, tf.CursorPosition);
Assert.Equal (new (3, 0), tf.PositionCursor ());
Assert.Equal ("📄a", tf.Text);
}
}