From 128591281d15dbf50b0a21bc9f6f1a55b96f1543 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Thu, 29 May 2025 23:00:32 +0100 Subject: [PATCH] Fixes #4096 Fix ctrl+Del ansi escape sequence not parsing (#4097) * Fix ctrl+Del ansi escape sequence not parsing * Add more tests * cleanup * xml doc --------- Co-authored-by: Tig --- .../Keyboard/AnsiKeyboardParser.cs | 1 + .../Keyboard/CsiCursorPattern.cs | 73 ++++++++++++ .../Keyboard/CsiKeyPattern.cs | 112 ++++++++---------- .../ConsoleDrivers/AnsiKeyboardParserTests.cs | 53 +++++++++ 4 files changed, 179 insertions(+), 60 deletions(-) create mode 100644 Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiCursorPattern.cs diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs index e63762653..42aa368e7 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs @@ -10,6 +10,7 @@ public class AnsiKeyboardParser { new Ss3Pattern (), new CsiKeyPattern (), + new CsiCursorPattern(), new EscAsAltPattern { IsLastMinute = true } }; diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiCursorPattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiCursorPattern.cs new file mode 100644 index 000000000..9d0fac963 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiCursorPattern.cs @@ -0,0 +1,73 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// +/// Detects ansi escape sequences in strings that have been read from +/// the terminal (see ). +/// Handles navigation CSI key parsing such as \x1b[A (Cursor up) +/// and \x1b[1;5A (Cursor up with Ctrl) +/// +public class CsiCursorPattern : AnsiKeyboardParserPattern +{ + private readonly Regex _pattern = new (@"^\u001b\[(?:1;(\d+))?([A-DHF])$"); + + private readonly Dictionary _cursorMap = new () + { + { 'A', Key.CursorUp }, + { 'B', Key.CursorDown }, + { 'C', Key.CursorRight }, + { 'D', Key.CursorLeft }, + { 'H', Key.Home }, + { 'F', Key.End } + }; + + /// + public override bool IsMatch (string? input) { return _pattern.IsMatch (input!); } + + /// + /// Called by the base class to determine the key that matches the input. + /// + /// + /// + protected override Key? GetKeyImpl (string? input) + { + Match match = _pattern.Match (input!); + + if (!match.Success) + { + return null; + } + + string modifierGroup = match.Groups [1].Value; + char terminator = match.Groups [2].Value [0]; + + if (!_cursorMap.TryGetValue (terminator, out Key? key)) + { + return null; + } + + if (string.IsNullOrEmpty (modifierGroup)) + { + return key; + } + + if (int.TryParse (modifierGroup, out int modifier)) + { + key = modifier switch + { + 2 => key.WithShift, + 3 => key.WithAlt, + 4 => key.WithAlt.WithShift, + 5 => key.WithCtrl, + 6 => key.WithCtrl.WithShift, + 7 => key.WithCtrl.WithAlt, + 8 => key.WithCtrl.WithAlt.WithShift, + _ => key + }; + } + + return key; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs index dce820c8d..d70d42da1 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs @@ -5,58 +5,39 @@ namespace Terminal.Gui; /// /// Detects ansi escape sequences in strings that have been read from -/// the terminal (see ). This pattern -/// handles keys that begin Esc[ e.g. Esc[A - cursor up +/// the terminal (see ). +/// Handles CSI key parsing such as \x1b[3;5~ (Delete with Ctrl) /// public class CsiKeyPattern : AnsiKeyboardParserPattern { - private readonly Dictionary _terminators = new() - { - { "A", Key.CursorUp }, - { "B", Key.CursorDown }, - { "C", Key.CursorRight }, - { "D", Key.CursorLeft }, - { "H", Key.Home }, // Home (older variant) - { "F", Key.End }, // End (older variant) - { "1~", Key.Home }, // Home (modern variant) - { "4~", Key.End }, // End (modern variant) - { "5~", Key.PageUp }, - { "6~", Key.PageDown }, - { "2~", Key.InsertChar }, - { "3~", Key.Delete }, - { "11~", Key.F1 }, - { "12~", Key.F2 }, - { "13~", Key.F3 }, - { "14~", Key.F4 }, - { "15~", Key.F5 }, - { "17~", Key.F6 }, - { "18~", Key.F7 }, - { "19~", Key.F8 }, - { "20~", Key.F9 }, - { "21~", Key.F10 }, - { "23~", Key.F11 }, - { "24~", Key.F12 } - }; + private readonly Regex _pattern = new (@"^\u001b\[(\d+)(?:;(\d+))?~$"); - private readonly Regex _pattern; + private readonly Dictionary _keyCodeMap = new () + { + { 1, Key.Home }, // Home (modern variant) + { 4, Key.End }, // End (modern variant) + { 5, Key.PageUp }, + { 6, Key.PageDown }, + { 2, Key.InsertChar }, + { 3, Key.Delete }, + { 11, Key.F1 }, + { 12, Key.F2 }, + { 13, Key.F3 }, + { 14, Key.F4 }, + { 15, Key.F5 }, + { 17, Key.F6 }, + { 18, Key.F7 }, + { 19, Key.F8 }, + { 20, Key.F9 }, + { 21, Key.F10 }, + { 23, Key.F11 }, + { 24, Key.F12 } + }; /// public override bool IsMatch (string? input) { return _pattern.IsMatch (input!); } - /// - /// Creates a new instance of the class. - /// - public CsiKeyPattern () - { - var terms = new string (_terminators.Select (k => k.Key [0]).Where (k => !char.IsDigit (k)).ToArray ()); - _pattern = new (@$"^\u001b\[(1;(\d+))?([{terms}]|\d+~)$"); - } - - /// - /// Called by the base class to determine the key that matches the input. - /// - /// - /// + /// protected override Key? GetKeyImpl (string? input) { Match match = _pattern.Match (input!); @@ -66,26 +47,37 @@ public class CsiKeyPattern : AnsiKeyboardParserPattern return null; } - string terminator = match.Groups [3].Value; - string modifierGroup = match.Groups [2].Value; + // Group 1: Key code (e.g. 3, 17, etc.) + // Group 2: Optional modifier code (e.g. 2 = Shift, 5 = Ctrl) - Key? key = _terminators.GetValueOrDefault (terminator); - - if (key is {} && int.TryParse (modifierGroup, out int modifier)) + if (!int.TryParse (match.Groups [1].Value, out int keyCode)) { - key = modifier switch - { - 2 => key.WithShift, - 3 => key.WithAlt, - 4 => key.WithAlt.WithShift, - 5 => key.WithCtrl, - 6 => key.WithCtrl.WithShift, - 7 => key.WithCtrl.WithAlt, - 8 => key.WithCtrl.WithAlt.WithShift, - _ => key - }; + return null; } + if (!_keyCodeMap.TryGetValue (keyCode, out Key? key)) + { + return null; + } + + // If there's no modifier, just return the key. + if (!int.TryParse (match.Groups [2].Value, out int modifier)) + { + return key; + } + + key = modifier switch + { + 2 => key.WithShift, + 3 => key.WithAlt, + 4 => key.WithAlt.WithShift, + 5 => key.WithCtrl, + 6 => key.WithCtrl.WithShift, + 7 => key.WithCtrl.WithAlt, + 8 => key.WithCtrl.WithAlt.WithShift, + _ => key + }; + return key; } } diff --git a/Tests/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs b/Tests/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs index 0dcbda50b..d7bcedd18 100644 --- a/Tests/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs +++ b/Tests/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs @@ -52,6 +52,59 @@ public class AnsiKeyboardParserTests yield return new object [] { "\u001b[1", null! }; yield return new object [] { "\u001b[AB", null! }; yield return new object [] { "\u001b[;A", null! }; + + + // Test data for various ANSI escape sequences and their expected Key values + yield return new object [] { "\u001b[3;5~", Key.Delete.WithCtrl }; + + // Additional special keys + yield return new object [] { "\u001b[H", Key.Home }; + yield return new object [] { "\u001b[F", Key.End }; + yield return new object [] { "\u001b[2~", Key.InsertChar }; + yield return new object [] { "\u001b[5~", Key.PageUp }; + yield return new object [] { "\u001b[6~", Key.PageDown }; + + // Home, End with modifiers + yield return new object [] { "\u001b[1;2H", Key.Home.WithShift }; + yield return new object [] { "\u001b[1;3H", Key.Home.WithAlt }; + yield return new object [] { "\u001b[1;5F", Key.End.WithCtrl }; + + // Insert with modifiers + yield return new object [] { "\u001b[2;2~", Key.InsertChar.WithShift }; + yield return new object [] { "\u001b[2;3~", Key.InsertChar.WithAlt }; + yield return new object [] { "\u001b[2;5~", Key.InsertChar.WithCtrl }; + + // PageUp/PageDown with modifiers + yield return new object [] { "\u001b[5;2~", Key.PageUp.WithShift }; + yield return new object [] { "\u001b[6;3~", Key.PageDown.WithAlt }; + yield return new object [] { "\u001b[6;5~", Key.PageDown.WithCtrl }; + + // Function keys F1-F4 (common ESC O sequences) + yield return new object [] { "\u001bOP", Key.F1 }; + yield return new object [] { "\u001bOQ", Key.F2 }; + yield return new object [] { "\u001bOR", Key.F3 }; + yield return new object [] { "\u001bOS", Key.F4 }; + + // Extended function keys F1-F12 with CSI sequences + yield return new object [] { "\u001b[11~", Key.F1 }; + yield return new object [] { "\u001b[12~", Key.F2 }; + yield return new object [] { "\u001b[13~", Key.F3 }; + yield return new object [] { "\u001b[14~", Key.F4 }; + yield return new object [] { "\u001b[15~", Key.F5 }; + yield return new object [] { "\u001b[17~", Key.F6 }; + yield return new object [] { "\u001b[18~", Key.F7 }; + yield return new object [] { "\u001b[19~", Key.F8 }; + yield return new object [] { "\u001b[20~", Key.F9 }; + yield return new object [] { "\u001b[21~", Key.F10 }; + yield return new object [] { "\u001b[23~", Key.F11 }; + yield return new object [] { "\u001b[24~", Key.F12 }; + + // Function keys with modifiers + /* Not currently supported + yield return new object [] { "\u001b[1;2P", Key.F1.WithShift }; + yield return new object [] { "\u001b[1;3Q", Key.F2.WithAlt }; + yield return new object [] { "\u001b[1;5R", Key.F3.WithCtrl }; + */ } // Consolidated test for all keyboard events (e.g., arrow keys)