diff --git a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs index f2a08ee85c..4792ece5a5 100644 --- a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs @@ -691,6 +691,40 @@ public void OnMouseEvent (MouseEventArgs a) /// If simulates the Ctrl key being pressed. 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; diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs index 48bcb713f5..f8101d5468 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs @@ -924,8 +924,11 @@ internal void ProcessInput () k &= ~KeyCode.Space; } - OnKeyDown (new Key (k)); - OnKeyUp (new Key (k)); + if (IsValidInput (k, out k)) + { + OnKeyDown (new (k)); + OnKeyUp (new (k)); + } } } diff --git a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs index 5e9a883d72..3dc0082428 100644 --- a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs @@ -352,8 +352,12 @@ private void MockKeyPressedHandler (ConsoleKeyInfo consoleKeyInfo) } 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)); } diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver/NetDriver.cs index 2ede00a000..59d75b5817 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver/NetDriver.cs @@ -321,8 +321,11 @@ private void ProcessInput (InputResult inputEvent) break; } - OnKeyDown (new (map)); - OnKeyUp (new (map)); + if (IsValidInput (map, out map)) + { + OnKeyDown (new (map)); + OnKeyUp (new (map)); + } break; case EventType.Mouse: diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs index 1f74c0321e..1fc1eb8424 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs @@ -507,9 +507,12 @@ internal void ProcessInputAfterParsing (WindowsConsole.InputRecord inputEvent) 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; diff --git a/Terminal.Gui/Input/Keyboard/Key.cs b/Terminal.Gui/Input/Keyboard/Key.cs index e0d4119aa3..dfcd34e672 100644 --- a/Terminal.Gui/Input/Keyboard/Key.cs +++ b/Terminal.Gui/Input/Keyboard/Key.cs @@ -138,6 +138,44 @@ public Key (string str) KeyCode = key.KeyCode; } + /// + /// 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. + /// + /// + /// Don't rely on passed from to because + /// would not return the expected keys from 'a' to 'z'. + /// + /// The integer describing the key. + /// + /// + 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; + } + /// /// 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 static Rune ToRune (KeyCode key) /// public static implicit operator Key (string str) { return new (str); } + /// Cast to a . + /// See for more information. + /// + public static implicit operator Key (int value) { return new (value); } + /// Cast a to a . /// See for more information. /// @@ -550,7 +593,7 @@ private static string TrimEndSeparator (string input, Rune separator) // "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 static bool TryParse (string text, out Key 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)) { diff --git a/Terminal.Gui/Views/TextField.cs b/Terminal.Gui/Views/TextField.cs index 6c872d95c4..3d83aaaafb 100644 --- a/Terminal.Gui/Views/TextField.cs +++ b/Terminal.Gui/Views/TextField.cs @@ -730,21 +730,11 @@ public virtual void DeleteCharRight () /// Use the previous cursor position. 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); } } @@ -1114,7 +1104,7 @@ public virtual void Paste () 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); diff --git a/Tests/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs b/Tests/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs index fbede01b02..de29652781 100644 --- a/Tests/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs +++ b/Tests/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs @@ -283,4 +283,115 @@ public void TerminalResized_Simulation (Type driverType) // 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 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 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 (); + } } diff --git a/Tests/UnitTests/Views/TextFieldTests.cs b/Tests/UnitTests/Views/TextFieldTests.cs index 14c84a282f..11d8ee92c2 100644 --- a/Tests/UnitTests/Views/TextFieldTests.cs +++ b/Tests/UnitTests/Views/TextFieldTests.cs @@ -2178,4 +2178,33 @@ public void Draw_Esc_Rune () tf.Dispose (); } + + [Fact] + public void InsertText_Bmp_SurrogatePair_Non_Bmp_Invalid_SurrogatePair () + { + var tf = new TextField (); + + //📄 == \ud83d\udcc4 == \U0001F4C4 + // � == Rune.ReplacementChar + tf.InsertText ("aA,;\ud83d\udcc4\U0001F4C4\udcc4\ud83d"); + Assert.Equal ("aA,;📄📄��", 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); + } } diff --git a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs index 0fcb4bead4..26605314a0 100644 --- a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs +++ b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs @@ -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 void AsRune_ShouldReturnCorrectIntValue (KeyCode key, uint expected) [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 void Cast_Key_ToString () [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 void Casting_Between_Key_And_KeyCode (KeyCode keyCode, KeyCode key) [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 (() => new Key (keyInt)); } + + [Theory] + [InlineData ('\ud83d')] + [InlineData ('\udcc4')] + public void Constructor_Int_Surrogate_Throws (int keyInt) { Assert.Throws (() => new Key (keyInt)); } + [Fact] public void Constructor_Default_ShouldSetKeyToNull () {