Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 7 additions & 0 deletions Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ private void BuildDriverIfPossible (IApplication? app)

ansiOutput.EnableKittyKeyboard (result.EnabledFlags);
_driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags);

// Enable kitty protocol deduplication in AnsiInput if applicable
if (_input is AnsiInput ansiInput)
{
ansiInput.KittyProtocolEnabled = true;
}

Trace.Lifecycle (app?.MainThreadId?.ToString (),
"KittyKeyboard",
$"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}");
Expand Down
29 changes: 29 additions & 0 deletions Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ public class AnsiInput : InputImpl<char>, ITestableInput<char>
// Queue for storing injected input that will be returned by Peek/Read
private readonly ConcurrentQueue<char> _testInput = new ();

private bool _kittyProtocolEnabled;
private char? _previousWindowsVTLastChar;
Comment thread
BDisp marked this conversation as resolved.
Outdated

/// <summary>
/// Gets or sets whether the kitty keyboard protocol is enabled for this input stream.
/// </summary>
internal bool KittyProtocolEnabled
{
get => _kittyProtocolEnabled;
set => _kittyProtocolEnabled = value;
}

private int _peekCallCount;

/// <summary>
Expand Down Expand Up @@ -205,6 +217,23 @@ public override IEnumerable<char> Read ()

string text = _windowsVTInput!.ConsoleInputEncoding.GetString (buffer, 0, bytesRead);

if (_kittyProtocolEnabled
&& _previousWindowsVTLastChar is { } lastChar
&& text.Length > 0
&& text [0] == lastChar)
{
text = text [1..];
}

if (text.Length == 0)
{
_previousWindowsVTLastChar = null;

yield break;
}

_previousWindowsVTLastChar = text [^1];

//Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}");

foreach (char ch in text)
Expand Down
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 @@ -249,6 +262,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 @@ -269,6 +283,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 @@ -281,6 +296,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 @@ -545,5 +573,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
Loading
Loading