diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index cf09a53825..925849d8f8 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -10,7 +10,7 @@ namespace Terminal.Gui.Drivers; /// public class KittyKeyboardPattern : AnsiKeyboardParserPattern { - private readonly Regex _pattern = new (@"^\u001b\[(\d+)(?::(\d+))?(?::(\d+))?(?:;([^;u]*))?(?:;([^u]+))?u$"); + private readonly Regex _pattern = new (@"^\u001b\[(\d+)(?::(\d*))?(?::(\d*))?(?:;([^;u]*))?(?:;([^u]+))?u$"); private readonly Dictionary _functionalKeyMap = new () { @@ -53,7 +53,17 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern { 57384, Key.F21 }, { 57385, Key.F22 }, { 57386, Key.F23 }, - { 57387, Key.F24 } + { 57387, Key.F24 }, + { 57417, Key.CursorLeft }, + { 57418, Key.CursorRight }, + { 57419, Key.CursorUp }, + { 57420, Key.CursorDown }, + { 57421, Key.PageUp }, + { 57422, Key.PageDown }, + { 57423, Key.Home }, + { 57424, Key.End }, + { 57425, Key.InsertChar }, + { 57426, Key.Delete } }; /// @@ -231,6 +241,9 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key { 57447, ModifierKey.RightShift }, { 57448, ModifierKey.RightCtrl }, { 57449, ModifierKey.RightAlt }, + // 57453 = ISO_Level3_Shift (AltGr). Treat it as a dedicated modifier so + // standalone AltGr does not fall through as a printable Private Use Area rune. + { 57453, ModifierKey.AltGr }, { 57450, ModifierKey.RightSuper }, { 57451, ModifierKey.RightHyper } diff --git a/Terminal.Gui/Input/Keyboard/ModifierKey.cs b/Terminal.Gui/Input/Keyboard/ModifierKey.cs index dbd0dabf94..70af63e615 100644 --- a/Terminal.Gui/Input/Keyboard/ModifierKey.cs +++ b/Terminal.Gui/Input/Keyboard/ModifierKey.cs @@ -47,6 +47,9 @@ public enum ModifierKey /// Right Alt. RightAlt, + /// AltGr / ISO Level 3 Shift. + AltGr, + /// Super / Windows / Cmd key (side not distinguished). Super, diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs index 0916a61a22..6ba6d25680 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs @@ -190,6 +190,17 @@ public void KittyPattern_RightAlt_Standalone () Assert.Equal (ModifierKey.RightAlt, key.ModifierKey); } + [Fact] + public void KittyPattern_AltGr_Standalone () + { + // ESC[57453u = AltGr / ISO_Level3_Shift + Key? key = _pattern.GetKey ("\u001b[57453u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.AltGr, key.ModifierKey); + } + [Fact] public void KittyPattern_CapsLock_Standalone () { @@ -224,6 +235,57 @@ public void KittyPattern_ModifierKey_WithEventType_Release () Assert.Equal (KeyEventType.Release, key.EventType); } + [Fact] + public void KittyPattern_AltGr_WithEventType_Release () + { + // ESC[57453;1:3u = AltGr / ISO_Level3_Shift, event type 3 (release) + Key? key = _pattern.GetKey ("\u001b[57453;1:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.AltGr, key.ModifierKey); + Assert.Equal (KeyEventType.Release, key.EventType); + } + + [Fact] + public void KittyPattern_LeftAlt_WithCtrlModifier_PreservesBothStates () + { + Key? key = _pattern.GetKey ("\u001b[57443;5u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.False (key.IsAlt); + Assert.Equal (KeyEventType.Press, key.EventType); + } + + [Fact] + public void KittyPattern_LeftAlt_Release_WithCtrlAndAltModifiers_PreservesBothStates () + { + Key? key = _pattern.GetKey ("\u001b[57443;7:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.True (key.IsAlt); + Assert.Equal (KeyEventType.Release, key.EventType); + } + + [Fact] + public void KittyPattern_LeftCtrl_Release_WithCtrlModifier_PreservesState () + { + Key? key = _pattern.GetKey ("\u001b[57442;5:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftCtrl, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.False (key.IsAlt); + Assert.Equal (KeyEventType.Release, key.EventType); + } + [Fact] public void KittyPattern_NonModifierKey_IsNotModifierOnly () { @@ -460,6 +522,58 @@ public void KittyPattern_AlternateKeys_FunctionalKey () Assert.Equal ((KeyCode)13, key.BaseLayoutKeyCode); } + [Fact] + public void KittyPattern_EmptyFields_WithAssociatedText () + { + // ESC[64::50;;64u = '@' with empty shifted field, base layout '2', empty modifiers, associated text '@' + Key? key = _pattern.GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.Equal (new Key ('@'), key); + Assert.Equal (KeyCode.Null, key.ShiftedKeyCode); + Assert.Equal ((KeyCode)50, key.BaseLayoutKeyCode); + Assert.Equal ("@", key.AssociatedText); + } + + [Fact] + public void KittyPattern_AltGr5_Press_ReturnsEuroGrapheme () + { + // ESC[8364;1:1u = Euro symbol, no modifiers, press + Key? key = _pattern.GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Press, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + + [Fact] + public void KittyPattern_AltGrE_EuroKey_Press_ReturnsEuroGrapheme () + { + // ESC[8364;1:1u = AltGr+E on many layouts, which sends the euro codepoint 8364. + Key? key = _pattern.GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Press, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + + [Fact] + public void KittyPattern_AltGrE_EuroKey_Release_ReturnsEuroGrapheme () + { + // ESC[8364;1:3u = Euro symbol, no modifiers, release + Key? key = _pattern.GetKey ("\u001b[8364;1:3u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Release, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + #endregion #region Kitty Flags Validation diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index 23328efdfc..0a1c4ea4ce 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -57,6 +57,19 @@ public void Pipeline_Press_RaisesKeyDown () Assert.Empty (up); } + [Fact] + public void Pipeline_AltGrE_Press_RaisesEuroKeyDown () + { + // ESC[8364;1:1u = Euro symbol press + (List down, List up) = InjectRawSequence ("\x1b[8364;1:1u"); + + Assert.Single (down); + Assert.Equal (KeyEventType.Press, down [0].EventType); + Assert.Equal ((KeyCode)8364, down [0].KeyCode); + Assert.Equal ("€", down [0].AsGrapheme); + Assert.Empty (up); + } + // Copilot - Opus 4.6 [Fact] public void Pipeline_Repeat_RaisesKeyDown () @@ -298,6 +311,7 @@ public void Pipeline_RightAlt_Press () [InlineData ("\x1b[57447u", ModifierKey.RightShift)] [InlineData ("\x1b[57448u", ModifierKey.RightCtrl)] [InlineData ("\x1b[57449u", ModifierKey.RightAlt)] + [InlineData ("\x1b[57453u", ModifierKey.AltGr)] public void Pipeline_ModifierPress_RaisesKeyDown (string sequence, ModifierKey expectedModifier) { // Standalone modifier press should raise app.Keyboard.KeyDown @@ -318,6 +332,7 @@ public void Pipeline_ModifierPress_RaisesKeyDown (string sequence, ModifierKey e [InlineData ("\x1b[57447;1:3u", ModifierKey.RightShift)] [InlineData ("\x1b[57448;1:3u", ModifierKey.RightCtrl)] [InlineData ("\x1b[57449;1:3u", ModifierKey.RightAlt)] + [InlineData ("\x1b[57453;1:3u", ModifierKey.AltGr)] public void Pipeline_ModifierRelease_RaisesKeyUp (string sequence, ModifierKey expectedModifier) { // Standalone modifier release should raise app.Keyboard.KeyUp @@ -330,6 +345,19 @@ public void Pipeline_ModifierRelease_RaisesKeyUp (string sequence, ModifierKey e Assert.Equal (KeyEventType.Release, up [0].EventType); } + [Fact] + public void Pipeline_LeftAltPress_WithCtrlModifier_PreservesBothStates () + { + (List down, List up) = InjectRawSequence ("\x1b[57443;5u"); + + Assert.Single (down); + Assert.True (down [0].IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, down [0].ModifierKey); + Assert.True (down [0].IsCtrl); + Assert.False (down [0].IsAlt); + Assert.Empty (up); + } + #endregion #region CSI ~ and Cursor Keys @@ -594,5 +622,55 @@ public void Pipeline_ValidAfterInvalid_StillWorks () Assert.Equal (KeyEventType.Release, keyUpEvents [0].EventType); } - #endregion + // Copilot - Opus 4.6 + [Fact] + public void Pipeline_KittyPrintableKey () + { + // Test that kitty CSI u sequence produces the expected key event + (List down, List up) = InjectRawSequence ( + "\x1b[171;2:1u" // ESC[171;2:1u = '«'(171) + Shift(2), press(1) - kitty sequence + ); + + // Should have one key event from kitty + Assert.Single (down); + // For Shift+«, the kitty sequence should produce a key with KeyCode = 171 and IsShift = true + Assert.Equal (171, down [0].AsRune.Value); + Assert.True (down [0].IsShift); + Assert.Empty (up); + } + + // Copilot - Opus 4.6 + // Theory test for alternative kitty code points (57417-57426) + [Theory] + [InlineData (57417, nameof (Key.CursorLeft))] + [InlineData (57418, nameof (Key.CursorRight))] + [InlineData (57419, nameof (Key.CursorUp))] + [InlineData (57420, nameof (Key.CursorDown))] + [InlineData (57421, nameof (Key.PageUp))] + [InlineData (57422, nameof (Key.PageDown))] + [InlineData (57423, nameof (Key.Home))] + [InlineData (57424, nameof (Key.End))] + [InlineData (57425, nameof (Key.InsertChar))] + [InlineData (57426, nameof (Key.Delete))] + public void Alternative_KittyCodePoints_Map_To_Correct_Keys (int kittyCode, string expectedKeyName) + { + // Arrange - Build the kitty sequence for the code point + string sequence = $"\x1b[{kittyCode}u"; // ESC[codePointu (press event, no modifiers) + + // Act + (List down, List up) = InjectRawSequence (sequence); + + // Assert - Should have exactly one key down event + Assert.Single (down); + + // Get the expected key by name from the Key class + System.Reflection.PropertyInfo? prop = typeof (Key).GetProperty (expectedKeyName); + Assert.NotNull (prop); + Key expectedKey = (Key)prop!.GetValue (null)!; + + // Verify the mapped key matches the expected key + Assert.Equal (expectedKey.KeyCode, down [0].KeyCode); + Assert.Empty (up); + } } +#endregion diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs index 576ca584b8..10fe37032a 100644 --- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs @@ -277,6 +277,84 @@ public void KittyAssociatedText_ShiftedPrintableKey_InsertsAssociatedText () top.Dispose (); } + [Fact] + public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key kittyKey = new () { ModifierKey = ModifierKey.AltGr }; + + Assert.False (top.NewKeyDownEvent (kittyKey)); + Assert.Equal (string.Empty, tf.Text); + + top.Dispose (); + } + + [Fact] + public void KittyAltGr5_InsertsEuroSymbol () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("€", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + + [Fact] + public void KittyAltGrE_InsertsEuroSymbol () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("€", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + + [Fact] + public void KittyAltGr2_InsertsAtSign () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("@", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + [Fact] public void ShiftedDigitKey_WithoutKittyMetadata_InsertsBaseDigit () { diff --git a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs index a67201a854..9d49854c2e 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs @@ -201,6 +201,81 @@ public void KittyAssociatedText_ShiftedPrintableKey_InsertsAssociatedText () Assert.Equal (new Point (1, 0), tv.InsertionPoint); } + [Fact] + public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key kittyKey = new () { ModifierKey = ModifierKey.AltGr }; + + Assert.False (tv.NewKeyDownEvent (kittyKey)); + Assert.Equal (string.Empty, tv.Text); + Assert.Equal (Point.Empty, tv.InsertionPoint); + } + + [Fact] + public void KittyAltGr5_InsertsEuroSymbol () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("€", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + + [Fact] + public void KittyAltGrE_InsertsEuroSymbol () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("€", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + + [Fact] + public void KittyAltGr2_InsertsAtSign () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("@", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + [Fact] public void InsertText_GraphemeSequence_PreservesSingleGrapheme () { diff --git a/global.json b/global.json index f823b7afc1..ceedb4c7af 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.0", + "version": "10.0.100", "rollForward": "latestMinor" }, "test": {