Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Terminal.Gui.Drivers;
/// </summary>
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<int, Key> _functionalKeyMap = new ()
{
Expand Down Expand Up @@ -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 }
};

/// <inheritdoc/>
Expand Down Expand Up @@ -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 }

Expand Down
3 changes: 3 additions & 0 deletions Terminal.Gui/Input/Keyboard/ModifierKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public enum ModifierKey
/// <summary>Right Alt.</summary>
RightAlt,

/// <summary>AltGr / ISO Level 3 Shift.</summary>
AltGr,

/// <summary>Super / Windows / Cmd key (side not distinguished).</summary>
Super,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down Expand Up @@ -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 ()
{
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Key> down, List<Key> 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 ()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<Key> down, List<Key> 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
Expand Down Expand Up @@ -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<Key> down, List<Key> 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<Key> down, List<Key> 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
78 changes: 78 additions & 0 deletions Tests/UnitTestsParallelizable/Views/TextFieldTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down
Loading
Loading