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

@@ -22,7 +22,7 @@ public abstract class ClipboardBase : IClipboard
return string.Empty;
}
return GetClipboardDataImpl ();
return result;
}
catch (NotSupportedException ex)
{

View File

@@ -691,6 +691,40 @@ public abstract class ConsoleDriver : IConsoleDriver
/// <param name="ctrl">If <see langword="true"/> simulates the Ctrl key being pressed.</param>
public abstract void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl);
internal char _highSurrogate = '\0';
internal bool IsValidInput (KeyCode keyCode, out KeyCode result)
{
result = keyCode;
if (char.IsHighSurrogate ((char)keyCode))
{
_highSurrogate = (char)keyCode;
return false;
}
if (_highSurrogate > 0 && char.IsLowSurrogate ((char)keyCode))
{
result = (KeyCode)new Rune (_highSurrogate, (char)keyCode).Value;
_highSurrogate = '\0';
return true;
}
if (char.IsSurrogate ((char)keyCode))
{
return false;
}
if (_highSurrogate > 0)
{
_highSurrogate = '\0';
}
return true;
}
#endregion
private AnsiRequestScheduler? _scheduler;

View File

@@ -203,7 +203,7 @@ internal class WSLClipboard : ClipboardBase
}
(int exitCode, string output) =
ClipboardProcessRunner.Process (_powershellPath, "-noprofile -command \"Get-Clipboard\"");
ClipboardProcessRunner.Process (_powershellPath, "-noprofile -command \"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard\"");
if (exitCode == 0)
{

View File

@@ -924,8 +924,11 @@ internal class CursesDriver : ConsoleDriver
k &= ~KeyCode.Space;
}
OnKeyDown (new Key (k));
OnKeyUp (new Key (k));
if (IsValidInput (k, out k))
{
OnKeyDown (new (k));
OnKeyUp (new (k));
}
}
}

View File

@@ -352,8 +352,12 @@ public class FakeDriver : ConsoleDriver
}
KeyCode map = MapKey (consoleKeyInfo);
OnKeyDown (new Key (map));
OnKeyUp (new Key (map));
if (IsValidInput (map, out map))
{
OnKeyDown (new (map));
OnKeyUp (new (map));
}
//OnKeyPressed (new KeyEventArgs (map));
}

View File

@@ -321,8 +321,11 @@ internal class NetDriver : ConsoleDriver
break;
}
OnKeyDown (new (map));
OnKeyUp (new (map));
if (IsValidInput (map, out map))
{
OnKeyDown (new (map));
OnKeyUp (new (map));
}
break;
case EventType.Mouse:

View File

@@ -507,9 +507,12 @@ internal class WindowsDriver : ConsoleDriver
break;
}
// This follows convention in NetDriver
OnKeyDown (new Key (map));
OnKeyUp (new Key (map));
if (IsValidInput (map, out map))
{
// This follows convention in NetDriver
OnKeyDown (new (map));
OnKeyUp (new (map));
}
break;

View File

@@ -138,6 +138,44 @@ public class Key : EventArgs, IEquatable<Key>
KeyCode = key.KeyCode;
}
/// <summary>
/// Constructs a new Key from an integer describing the key.
/// It parses the integer as Key by calling the constructor with a char or calls the constructor with a
/// KeyCode.
/// </summary>
/// <remarks>
/// Don't rely on <paramref name="value"/> passed from <see cref="KeyCode.A"/> to <see cref="KeyCode.Z"/> because
/// would not return the expected keys from 'a' to 'z'.
/// </remarks>
/// <param name="value">The integer describing the key.</param>
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <exception cref="ArgumentException"></exception>
public Key (int value)
{
if (value < 0 || value > RuneExtensions.MaxUnicodeCodePoint)
{
throw new ArgumentOutOfRangeException (@$"Invalid key value: {value}", nameof (value));
}
if (char.IsSurrogate ((char)value))
{
throw new ArgumentException (@$"Surrogate key not allowed: {value}", nameof (value));
}
Key key;
if (((Rune)value).IsBmp)
{
key = new ((char)value);
}
else
{
key = new ((KeyCode)value);
}
KeyCode = key.KeyCode;
}
/// <summary>
/// The key value as a Rune. This is the actual value of the key pressed, and is independent of the modifiers.
/// Useful for determining if a key represents is a printable character.
@@ -388,6 +426,11 @@ public class Key : EventArgs, IEquatable<Key>
/// <param name="str"></param>
public static implicit operator Key (string str) { return new (str); }
/// <summary>Cast <see langword="int"/> to a <see cref="Key"/>.</summary>
/// <remarks>See <see cref="Key(int)"/> for more information.</remarks>
/// <param name="value"></param>
public static implicit operator Key (int value) { return new (value); }
/// <summary>Cast a <see cref="Key"/> to a <see langword="string"/>.</summary>
/// <remarks>See <see cref="Key(string)"/> for more information.</remarks>
/// <param name="key"></param>
@@ -550,7 +593,7 @@ public class Key : EventArgs, IEquatable<Key>
// "Ctrl+" (trim)
// "Ctrl++" (trim)
if (input.Length > 1 && new Rune (input [^1]) == separator && new Rune (input [^2]) != separator)
if (input.Length > 1 && !char.IsHighSurrogate (input [^2]) && new Rune (input [^1]) == separator && new Rune (input [^2]) != separator)
{
return input [..^1];
}
@@ -640,6 +683,13 @@ public class Key : EventArgs, IEquatable<Key>
return false;
}
if (text.Length == 2 && char.IsHighSurrogate (text [^2]) && char.IsLowSurrogate (text [^1]))
{
// It's a surrogate pair and there is no modifiers
key = new (new Rune (text [^2], text [^1]).Value);
return true;
}
// e.g. "Ctrl++"
if ((Rune)text [^1] != separator && parts.Any (string.IsNullOrEmpty))
{

View File

@@ -729,21 +729,11 @@ public class TextField : View
/// <param name="useOldCursorPos">Use the previous cursor position.</param>
public void InsertText (string toAdd, bool useOldCursorPos = true)
{
foreach (char ch in toAdd)
foreach (Rune rune in toAdd.EnumerateRunes ())
{
Key key;
try
{
key = ch;
}
catch (Exception)
{
throw new ArgumentException (
$"Cannot insert character '{ch}' because it does not map to a Key"
);
}
// All rune can be mapped to a Key and no exception will throw here because
// EnumerateRunes will replace a surrogate char with the Rune.ReplacementChar
Key key = rune.Value;
InsertText (key, useOldCursorPos);
}
}
@@ -1072,14 +1062,20 @@ public class TextField : View
/// <summary>Paste the selected text from the clipboard.</summary>
public virtual void Paste ()
{
if (ReadOnly || string.IsNullOrEmpty (Clipboard.Contents))
if (ReadOnly)
{
return;
}
string cbTxt = Clipboard.Contents.Split ("\n") [0] ?? "";
if (string.IsNullOrEmpty (cbTxt))
{
return;
}
SetSelectedStartSelectedLength ();
int selStart = _start == -1 ? CursorPosition : _start;
string cbTxt = Clipboard.Contents.Split ("\n") [0] ?? "";
Text = StringExtensions.ToString (_text.GetRange (0, selStart))
+ cbTxt
@@ -1114,7 +1110,7 @@ public class TextField : View
TextModel.SetCol (ref col, Viewport.Width - 1, cols);
}
int pos = _cursorPosition - ScrollOffset + Math.Min (Viewport.X, 0);
int pos = col - ScrollOffset + Math.Min (Viewport.X, 0);
Move (pos, 0);
return new Point (pos, 0);

View File

@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" />
<PackageReference Include="xunit" />
</ItemGroup>
</Project>

View File

@@ -1,25 +0,0 @@
using Xunit;
namespace TerminalGuiFluentTesting;
public static class XunitContextExtensions
{
public static GuiTestContext AssertTrue (this GuiTestContext context, bool? condition)
{
context.Then (
() =>
{
Assert.True (condition);
});
return context;
}
public static GuiTestContext AssertEqual (this GuiTestContext context, object? expected, object? actual)
{
context.Then (
() =>
{
Assert.Equal (expected,actual);
});
return context;
}
}

View File

@@ -11,7 +11,9 @@
<ItemGroup>
<ProjectReference Include="..\TerminalGuiFluentTestingXunit.Generator\TerminalGuiFluentTestingXunit.Generator.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>

View File

@@ -283,4 +283,115 @@ public class ConsoleDriverTests
// Application.Run (win);
// Application.Shutdown ();
// }
[Theory]
[InlineData ('\ud83d', '\udcc4')] // This seems right sequence but Stack is LIFO
[InlineData ('\ud83d', '\ud83d')]
[InlineData ('\udcc4', '\udcc4')]
public void FakeDriver_IsValidInput_Wrong_Surrogate_Sequence (char c1, char c2)
{
var driver = (IConsoleDriver)Activator.CreateInstance (typeof (FakeDriver));
Application.Init (driver);
Stack<ConsoleKeyInfo> mKeys = new (
[
new ('a', ConsoleKey.A, false, false, false),
new (c1, ConsoleKey.None, false, false, false),
new (c2, ConsoleKey.None, false, false, false)
]);
Console.MockKeyPresses = mKeys;
Toplevel top = new ();
var view = new View { CanFocus = true };
var rText = "";
var idx = 0;
view.KeyDown += (s, e) =>
{
Assert.Equal (new ('a'), e.AsRune);
Assert.Equal ("a", e.AsRune.ToString ());
rText += e.AsRune;
e.Handled = true;
idx++;
};
top.Add (view);
Application.Iteration += (s, a) =>
{
if (mKeys.Count == 0)
{
Application.RequestStop ();
}
};
Application.Run (top);
Assert.Equal ("a", rText);
Assert.Equal (1, idx);
Assert.Equal (0, ((FakeDriver)driver)._highSurrogate);
top.Dispose ();
// Shutdown must be called to safely clean up Application if Init has been called
Application.Shutdown ();
}
[Fact]
public void FakeDriver_IsValidInput_Correct_Surrogate_Sequence ()
{
var driver = (IConsoleDriver)Activator.CreateInstance (typeof (FakeDriver));
Application.Init (driver);
Stack<ConsoleKeyInfo> mKeys = new (
[
new ('a', ConsoleKey.A, false, false, false),
new ('\udcc4', ConsoleKey.None, false, false, false),
new ('\ud83d', ConsoleKey.None, false, false, false)
]);
Console.MockKeyPresses = mKeys;
Toplevel top = new ();
var view = new View { CanFocus = true };
var rText = "";
var idx = 0;
view.KeyDown += (s, e) =>
{
if (idx == 0)
{
Assert.Equal (new (0x1F4C4), e.AsRune);
Assert.Equal ("📄", e.AsRune.ToString ());
}
else
{
Assert.Equal (new ('a'), e.AsRune);
Assert.Equal ("a", e.AsRune.ToString ());
}
rText += e.AsRune;
e.Handled = true;
idx++;
};
top.Add (view);
Application.Iteration += (s, a) =>
{
if (mKeys.Count == 0)
{
Application.RequestStop ();
}
};
Application.Run (top);
Assert.Equal ("📄a", rText);
Assert.Equal (2, idx);
top.Dispose ();
// Shutdown must be called to safely clean up Application if Init has been called
Application.Shutdown ();
}
}

View File

@@ -78,27 +78,6 @@ public class TextFieldTests (ITestOutputHelper output)
}
}
[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]
[TextFieldTestsAutoInitShutdown]
public void CanFocus_False_Wont_Focus_With_Mouse ()
@@ -506,77 +485,6 @@ public class TextFieldTests (ITestOutputHelper output)
top.Dispose ();
}
[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.");
Application.Top = new ();
Application.Top.Add (tf);
tf.SetFocus ();
Application.RaiseKeyDownEvent (Key.Space);
Application.Top.Dispose ();
Application.ResetState (true);
}
[Fact]
public void Enter_Does_Not_Raise_Selected ()
{
TextField tf = new ();
var selectingCount = 0;
tf.Selecting += (sender, args) => selectingCount++;
Application.Top = new ();
Application.Top.Add (tf);
tf.SetFocus ();
Application.RaiseKeyDownEvent (Key.Enter);
Assert.Equal (0, selectingCount);
Application.Top.Dispose ();
Application.ResetState (true);
}
[Fact]
public void Enter_Raises_Accepted ()
{
TextField tf = new ();
var acceptedCount = 0;
tf.Accepting += (sender, args) => acceptedCount++;
Application.Top = new ();
Application.Top.Add (tf);
tf.SetFocus ();
Application.RaiseKeyDownEvent (Key.Enter);
Assert.Equal (1, acceptedCount);
Application.Top.Dispose ();
Application.ResetState (true);
}
[Fact]
[AutoInitShutdown (useFakeClipboard: true)]
public void KeyBindings_Command ()
@@ -811,47 +719,6 @@ public class TextFieldTests (ITestOutputHelper output)
Assert.Equal ("", tf.Text);
}
[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; }
}
[Theory]
[InlineData (false, 1)]
[InlineData (true, 0)]
@@ -904,87 +771,6 @@ public class TextFieldTests (ITestOutputHelper output)
void ButtonAccept (object sender, CommandEventArgs e) { buttonAccept++; }
}
[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]
[TextFieldTestsAutoInitShutdown]
public void Paste_Always_Clear_The_SelectedText ()
@@ -997,59 +783,6 @@ public class TextFieldTests (ITestOutputHelper output)
Assert.Null (_textField.SelectedText);
}
[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]
[AutoInitShutdown]
public void ScrollOffset_Initialize ()
@@ -1142,36 +875,6 @@ public class TextFieldTests (ITestOutputHelper output)
Assert.Null (_textField.SelectedText);
}
[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;
}
}
}
[Fact]
[AutoInitShutdown]
public void MouseEvent_Handled_Prevents_RightClick ()
@@ -1222,33 +925,6 @@ public class TextFieldTests (ITestOutputHelper output)
}
}
[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]
[TextFieldTestsAutoInitShutdown]
public void Text_Replaces_Tabs_With_Empty_String ()
@@ -1296,22 +972,6 @@ public class TextFieldTests (ITestOutputHelper output)
Assert.Equal ("changing", _textField.Text);
}
[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]
[TextFieldTestsAutoInitShutdown]
public void Used_Is_False ()
@@ -1631,77 +1291,6 @@ public class TextFieldTests (ITestOutputHelper output)
}
}
[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]
[TextFieldTestsAutoInitShutdown]
public void WordForward_With_No_Selection ()
@@ -2056,115 +1645,6 @@ Les Miśerables",
}
}
[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]
[AutoInitShutdown]
public void Draw_Esc_Rune ()

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