diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser.cs index b8de3d9ec..f6a2403d1 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser.cs @@ -2,45 +2,31 @@ namespace Terminal.Gui; - // Enum to manage the parser's state - public enum ParserState - { - Normal, - ExpectingBracket, - InResponse - } +internal abstract class AnsiResponseParserBase : IAnsiResponseParser +{ + protected readonly List<(string terminator, Action response)> expectedResponses = new (); + private AnsiResponseParserState _state = AnsiResponseParserState.Normal; - public interface IAnsiResponseParser + // Current state of the parser + public AnsiResponseParserState State { - void ExpectResponse (string terminator, Action response); - } - - internal abstract class AnsiResponseParserBase : IAnsiResponseParser - { - protected readonly List<(string terminator, Action response)> expectedResponses = new (); - private ParserState _state = ParserState.Normal; - - // Current state of the parser - public ParserState State + get => _state; + protected set { - get => _state; - protected set - { - StateChangedAt = DateTime.Now; - _state = value; - } + StateChangedAt = DateTime.Now; + _state = value; } + } - /// - /// When was last changed. - /// - public DateTime StateChangedAt { get; private set; } = DateTime.Now; + /// + /// When was last changed. + /// + public DateTime StateChangedAt { get; private set; } = DateTime.Now; - protected readonly HashSet _knownTerminators = new (); - - public AnsiResponseParserBase () - { + protected readonly HashSet _knownTerminators = new (); + public AnsiResponseParserBase () + { // These all are valid terminators on ansi responses, // see CSI in https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s _knownTerminators.Add ('@'); @@ -98,206 +84,217 @@ namespace Terminal.Gui; _knownTerminators.Add ('x'); _knownTerminators.Add ('y'); _knownTerminators.Add ('z'); - } - - protected void ResetState () - { - State = ParserState.Normal; - ClearHeld (); - } - - public abstract void ClearHeld (); - protected abstract string HeldToString (); - protected abstract IEnumerable HeldToObjects (); - protected abstract void AddToHeld (object o); - - /// - /// Processes an input collection of objects long. - /// You must provide the indexers to return the objects and the action to append - /// to output stream. - /// - /// The character representation of element i of your input collection - /// The actual element in the collection (e.g. char or Tuple<char,T>) - /// Action to invoke when parser confirms an element of the current collection or a previous - /// call's collection should be appended to the current output (i.e. append to your output List/StringBuilder). - /// The total number of elements in your collection - protected void ProcessInputBase ( - Func getCharAtIndex, - Func getObjectAtIndex, - Action appendOutput, - int inputLength) - { - var index = 0; // Tracks position in the input string - - while (index < inputLength) - { - var currentChar = getCharAtIndex (index); - var currentObj = getObjectAtIndex (index); - - bool isEscape = currentChar == '\x1B'; - - switch (State) - { - case ParserState.Normal: - if (isEscape) - { - // Escape character detected, move to ExpectingBracket state - State = ParserState.ExpectingBracket; - AddToHeld (currentObj); // Hold the escape character - } - else - { - // Normal character, append to output - appendOutput (currentObj); - } - break; - - case ParserState.ExpectingBracket: - if (isEscape) - { - // Second escape so we must release first - ReleaseHeld (appendOutput, ParserState.ExpectingBracket); - AddToHeld (currentObj); // Hold the new escape - } - else - if (currentChar == '[') - { - // Detected '[', transition to InResponse state - State = ParserState.InResponse; - AddToHeld (currentObj); // Hold the '[' - } - else - { - // Invalid sequence, release held characters and reset to Normal - ReleaseHeld (appendOutput); - appendOutput (currentObj); // Add current character - } - break; - - case ParserState.InResponse: - AddToHeld (currentObj); - - // Check if the held content should be released - if (ShouldReleaseHeldContent ()) - { - ReleaseHeld (appendOutput); - } - break; - } - - index++; - } - } - - - private void ReleaseHeld (Action appendOutput, ParserState newState = ParserState.Normal) - { - foreach (var o in HeldToObjects ()) - { - appendOutput (o); - } - - State = newState; - ClearHeld (); } - // Common response handler logic - protected bool ShouldReleaseHeldContent () - { - string cur = HeldToString (); - - // Check for expected responses - (string terminator, Action response) matchingResponse = expectedResponses.FirstOrDefault (r => cur.EndsWith (r.terminator)); - - if (matchingResponse.response != null) - { - DispatchResponse (matchingResponse.response); - expectedResponses.Remove (matchingResponse); - return false; - } - - if (_knownTerminators.Contains (cur.Last ()) && cur.StartsWith (EscSeqUtils.CSI)) - { - // Detected a response that was not expected - return true; - } - - return false; // Continue accumulating - } - - - protected void DispatchResponse (Action response) - { - response?.Invoke (HeldToString ()); - ResetState (); - } - - /// - /// Registers a new expected ANSI response with a specific terminator and a callback for when the response is completed. - /// - public void ExpectResponse (string terminator, Action response) => expectedResponses.Add ((terminator, response)); - } - - internal class AnsiResponseParser : AnsiResponseParserBase + protected void ResetState () { - private readonly List> held = new (); + State = AnsiResponseParserState.Normal; + ClearHeld (); + } - public IEnumerable> ProcessInput (params Tuple [] input) - { - var output = new List> (); - ProcessInputBase ( - i => input [i].Item1, - i => input [i], - c => output.Add ((Tuple)c), - input.Length); - return output; - } + public abstract void ClearHeld (); + protected abstract string HeldToString (); + protected abstract IEnumerable HeldToObjects (); + protected abstract void AddToHeld (object o); - public IEnumerable> Release () + /// + /// Processes an input collection of objects long. + /// You must provide the indexers to return the objects and the action to append + /// to output stream. + /// + /// The character representation of element i of your input collection + /// The actual element in the collection (e.g. char or Tuple<char,T>) + /// + /// Action to invoke when parser confirms an element of the current collection or a previous + /// call's collection should be appended to the current output (i.e. append to your output List/StringBuilder). + /// + /// The total number of elements in your collection + protected void ProcessInputBase ( + Func getCharAtIndex, + Func getObjectAtIndex, + Action appendOutput, + int inputLength + ) + { + var index = 0; // Tracks position in the input string + + while (index < inputLength) { - foreach (var h in held.ToArray ()) + char currentChar = getCharAtIndex (index); + object currentObj = getObjectAtIndex (index); + + bool isEscape = currentChar == '\x1B'; + + switch (State) { - yield return h; + case AnsiResponseParserState.Normal: + if (isEscape) + { + // Escape character detected, move to ExpectingBracket state + State = AnsiResponseParserState.ExpectingBracket; + AddToHeld (currentObj); // Hold the escape character + } + else + { + // Normal character, append to output + appendOutput (currentObj); + } + + break; + + case AnsiResponseParserState.ExpectingBracket: + if (isEscape) + { + // Second escape so we must release first + ReleaseHeld (appendOutput, AnsiResponseParserState.ExpectingBracket); + AddToHeld (currentObj); // Hold the new escape + } + else if (currentChar == '[') + { + // Detected '[', transition to InResponse state + State = AnsiResponseParserState.InResponse; + AddToHeld (currentObj); // Hold the '[' + } + else + { + // Invalid sequence, release held characters and reset to Normal + ReleaseHeld (appendOutput); + appendOutput (currentObj); // Add current character + } + + break; + + case AnsiResponseParserState.InResponse: + AddToHeld (currentObj); + + // Check if the held content should be released + if (ShouldReleaseHeldContent ()) + { + ReleaseHeld (appendOutput); + } + + break; } - ResetState (); + + index++; + } + } + + private void ReleaseHeld (Action appendOutput, AnsiResponseParserState newState = AnsiResponseParserState.Normal) + { + foreach (object o in HeldToObjects ()) + { + appendOutput (o); } - public override void ClearHeld () => held.Clear (); + State = newState; + ClearHeld (); + } - protected override string HeldToString () => new string (held.Select (h => h.Item1).ToArray ()); + // Common response handler logic + protected bool ShouldReleaseHeldContent () + { + string cur = HeldToString (); - protected override IEnumerable HeldToObjects () => held; + // Check for expected responses + (string terminator, Action response) matchingResponse = expectedResponses.FirstOrDefault (r => cur.EndsWith (r.terminator)); - protected override void AddToHeld (object o) => held.Add ((Tuple)o); + if (matchingResponse.response != null) + { + DispatchResponse (matchingResponse.response); + expectedResponses.Remove (matchingResponse); + return false; + } + if (_knownTerminators.Contains (cur.Last ()) && cur.StartsWith (EscSeqUtils.CSI)) + { + // Detected a response that was not expected + return true; + } + + return false; // Continue accumulating + } + + protected void DispatchResponse (Action response) + { + response?.Invoke (HeldToString ()); + ResetState (); + } + + /// + /// Registers a new expected ANSI response with a specific terminator and a callback for when the response is + /// completed. + /// + public void ExpectResponse (string terminator, Action response) { expectedResponses.Add ((terminator, response)); } } - internal class AnsiResponseParser : AnsiResponseParserBase +internal class AnsiResponseParser : AnsiResponseParserBase +{ + private readonly List> held = new (); + + public IEnumerable> ProcessInput (params Tuple [] input) { - private readonly StringBuilder held = new (); + List> output = new List> (); - public string ProcessInput (string input) + ProcessInputBase ( + i => input [i].Item1, + i => input [i], + c => output.Add ((Tuple)c), + input.Length); + + return output; + } + + public IEnumerable> Release () + { + foreach (Tuple h in held.ToArray ()) { - var output = new StringBuilder (); - ProcessInputBase ( - i => input [i], - i => input [i], // For string there is no T so object is same as char - c => output.Append ((char)c), - input.Length); - return output.ToString (); + yield return h; } - public string Release () - { - var output = held.ToString (); - ResetState (); - return output; - } - public override void ClearHeld () => held.Clear (); + ResetState (); + } - protected override string HeldToString () => held.ToString (); + public override void ClearHeld () { held.Clear (); } - protected override IEnumerable HeldToObjects () => held.ToString().Select(c => (object) c).ToArray (); - protected override void AddToHeld (object o) => held.Append ((char)o); - } \ No newline at end of file + protected override string HeldToString () { return new (held.Select (h => h.Item1).ToArray ()); } + + protected override IEnumerable HeldToObjects () { return held; } + + protected override void AddToHeld (object o) { held.Add ((Tuple)o); } +} + +internal class AnsiResponseParser : AnsiResponseParserBase +{ + private readonly StringBuilder held = new (); + + public string ProcessInput (string input) + { + var output = new StringBuilder (); + + ProcessInputBase ( + i => input [i], + i => input [i], // For string there is no T so object is same as char + c => output.Append ((char)c), + input.Length); + + return output.ToString (); + } + + public string Release () + { + var output = held.ToString (); + ResetState (); + + return output; + } + + public override void ClearHeld () { held.Clear (); } + + protected override string HeldToString () { return held.ToString (); } + + protected override IEnumerable HeldToObjects () { return held.ToString ().Select (c => (object)c).ToArray (); } + + protected override void AddToHeld (object o) { held.Append ((char)o); } +} diff --git a/Terminal.Gui/ConsoleDrivers/IAnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/IAnsiResponseParser.cs new file mode 100644 index 000000000..f82cf148a --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/IAnsiResponseParser.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace Terminal.Gui; + +public interface IAnsiResponseParser +{ + AnsiResponseParserState State { get; } + void ExpectResponse (string terminator, Action response); +} diff --git a/Terminal.Gui/ConsoleDrivers/ParserState.cs b/Terminal.Gui/ConsoleDrivers/ParserState.cs new file mode 100644 index 000000000..934b6eb3e --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/ParserState.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui; + +/// +/// Describes the current state of an +/// +public enum AnsiResponseParserState +{ + /// + /// Parser is reading normal input e.g. keys typed by user. + /// + Normal, + + /// + /// Parser has encountered an Esc and is waiting to see if next + /// key(s) continue to form an Ansi escape sequence + /// + ExpectingBracket, + + /// + /// Parser has encountered Esc[ and considers that it is in the process + /// of reading an ANSI sequence. + /// + InResponse +} diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index 3a6b2b3b7..0340ad8bd 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -1566,7 +1566,7 @@ internal class WindowsDriver : ConsoleDriver public IEnumerable ShouldRelease () { - if (Parser.State == ParserState.ExpectingBracket && + if (Parser.State == AnsiResponseParserState.ExpectingBracket && DateTime.Now - Parser.StateChangedAt > _escTimeout) { return Parser.Release ().Select (o => o.Item2); diff --git a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs index b80be5ae3..71b892b5f 100644 --- a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs +++ b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs @@ -154,7 +154,7 @@ public class AnsiResponseParserTests (ITestOutputHelper output) null, new [] { - new StepExpectation ('\u001b',ParserState.ExpectingBracket,string.Empty) + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty) } ]; @@ -164,13 +164,13 @@ public class AnsiResponseParserTests (ITestOutputHelper output) 'c', new [] { - new StepExpectation ('\u001b',ParserState.ExpectingBracket,string.Empty), - new StepExpectation ('H',ParserState.Normal,"\u001bH"), // H is known terminator and not expected one so here we release both chars - new StepExpectation ('\u001b',ParserState.ExpectingBracket,string.Empty), - new StepExpectation ('[',ParserState.InResponse,string.Empty), - new StepExpectation ('0',ParserState.InResponse,string.Empty), - new StepExpectation ('c',ParserState.Normal,string.Empty,"\u001b[0c"), // c is expected terminator so here we swallow input and populate expected response - new StepExpectation ('\u001b',ParserState.ExpectingBracket,string.Empty), + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), + new StepExpectation ('H',AnsiResponseParserState.Normal,"\u001bH"), // H is known terminator and not expected one so here we release both chars + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), + new StepExpectation ('[',AnsiResponseParserState.InResponse,string.Empty), + new StepExpectation ('0',AnsiResponseParserState.InResponse,string.Empty), + new StepExpectation ('c',AnsiResponseParserState.Normal,string.Empty,"\u001b[0c"), // c is expected terminator so here we swallow input and populate expected response + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), } ]; } @@ -186,7 +186,7 @@ public class AnsiResponseParserTests (ITestOutputHelper output) /// What should the state of the parser be after the /// is fed in. /// - public ParserState ExpectedStateAfterOperation { get; } + public AnsiResponseParserState ExpectedStateAfterOperation { get; } /// /// If this step should release one or more characters, put them here. @@ -201,7 +201,7 @@ public class AnsiResponseParserTests (ITestOutputHelper output) public StepExpectation ( char input, - ParserState expectedStateAfterOperation, + AnsiResponseParserState expectedStateAfterOperation, string expectedRelease = "", string expectedAnsiResponse = "") : this () { @@ -261,8 +261,8 @@ public class AnsiResponseParserTests (ITestOutputHelper output) AssertConsumed (input,ref i); // We should know when the state changed - Assert.Equal (ParserState.ExpectingBracket, _parser1.State); - Assert.Equal (ParserState.ExpectingBracket, _parser2.State); + Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser1.State); + Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser2.State); Assert.Equal (DateTime.Now.Date, _parser1.StateChangedAt.Date); Assert.Equal (DateTime.Now.Date, _parser2.StateChangedAt.Date); @@ -300,14 +300,14 @@ public class AnsiResponseParserTests (ITestOutputHelper output) // First Esc gets grabbed AssertConsumed (input, ref i); // Esc - Assert.Equal (ParserState.ExpectingBracket,_parser1.State); - Assert.Equal (ParserState.ExpectingBracket, _parser2.State); + Assert.Equal (AnsiResponseParserState.ExpectingBracket,_parser1.State); + Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser2.State); // Because next char is 'f' we do not see a bracket so release both AssertReleased (input, ref i, "\u001bf", 0,1); // f - Assert.Equal (ParserState.Normal, _parser1.State); - Assert.Equal (ParserState.Normal, _parser2.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser1.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser2.State); AssertReleased (input, ref i,"i",2); AssertReleased (input, ref i, "s", 3); @@ -428,7 +428,7 @@ public class AnsiResponseParserTests (ITestOutputHelper output) Assert.Equal (expectedRelease, _parser2.Release ()); - Assert.Equal (ParserState.Normal, _parser1.State); - Assert.Equal (ParserState.Normal, _parser2.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser1.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser2.State); } }