#nullable enable using Microsoft.Extensions.Logging; namespace Terminal.Gui; internal abstract class AnsiResponseParserBase : IAnsiResponseParser { private const char ESCAPE = '\x1B'; private readonly AnsiMouseParser _mouseParser = new (); #pragma warning disable IDE1006 // Naming Styles protected readonly AnsiKeyboardParser _keyboardParser = new (); protected object _lockExpectedResponses = new (); protected object _lockState = new (); protected readonly IHeld _heldContent; /// /// Responses we are expecting to come in. /// protected readonly List _expectedResponses = []; /// /// Collection of responses that we . /// protected readonly List _lateResponses = []; /// /// Responses that you want to look out for that will come in continuously e.g. mouse events. /// Key is the terminator. /// protected readonly List _persistentExpectations = []; #pragma warning restore IDE1006 // Naming Styles /// /// Event raised when mouse events are detected - requires setting to true /// public event EventHandler? Mouse; /// /// Event raised when keyboard event is detected (e.g. cursors) - requires setting /// public event EventHandler? Keyboard; /// /// True to explicitly handle mouse escape sequences by passing them to event. /// Defaults to /// public bool HandleMouse { get; set; } = false; /// /// True to explicitly handle keyboard escape sequences (such as cursor keys) by passing them to /// event /// public bool HandleKeyboard { get; set; } = false; private AnsiResponseParserState _state = AnsiResponseParserState.Normal; /// public AnsiResponseParserState State { get => _state; protected set { StateChangedAt = DateTime.Now; _state = value; } } /// /// When was last changed. /// public DateTime StateChangedAt { get; private set; } = DateTime.Now; // 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 // No - N or O protected readonly HashSet _knownTerminators = [ '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'G', 'H', 'I', 'J', 'K', 'L', 'M', // No - N or O 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Z', '^', '`', '~', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' ]; protected AnsiResponseParserBase (IHeld heldContent) { _heldContent = heldContent; } protected void ResetState () { State = AnsiResponseParserState.Normal; lock (_lockState) { _heldContent.ClearHeld (); } } /// /// 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 ) { lock (_lockState) { ProcessInputBaseImpl (getCharAtIndex, getObjectAtIndex, appendOutput, inputLength); } } private void ProcessInputBaseImpl ( Func getCharAtIndex, Func getObjectAtIndex, Action appendOutput, int inputLength ) { var index = 0; // Tracks position in the input string while (index < inputLength) { char currentChar = getCharAtIndex (index); object currentObj = getObjectAtIndex (index); bool isEscape = currentChar == ESCAPE; switch (State) { case AnsiResponseParserState.Normal: if (isEscape) { // Escape character detected, move to ExpectingBracket state State = AnsiResponseParserState.ExpectingEscapeSequence; _heldContent.AddToHeld (currentObj); // Hold the escape character } else { // Normal character, append to output appendOutput (currentObj); } break; case AnsiResponseParserState.ExpectingEscapeSequence: if (isEscape) { // Second escape so we must release first ReleaseHeld (appendOutput, AnsiResponseParserState.ExpectingEscapeSequence); _heldContent.AddToHeld (currentObj); // Hold the new escape } else if (_heldContent.Length == 1) { //We need O for SS3 mode F1-F4 e.g. "OP" => F1 //We need any letter or digit for Alt+Letter (see EscAsAltPattern) //In fact lets just always see what comes after esc // Detected '[' or 'O', transition to InResponse state State = AnsiResponseParserState.InResponse; _heldContent.AddToHeld (currentObj); // Hold the letter } else { // Invalid sequence, release held characters and reset to Normal ReleaseHeld (appendOutput); appendOutput (currentObj); // Add current character } break; case AnsiResponseParserState.InResponse: // if seeing another esc, we must resolve the current one first if (isEscape) { ReleaseHeld (appendOutput); State = AnsiResponseParserState.ExpectingEscapeSequence; _heldContent.AddToHeld (currentObj); } else { // Non esc, so continue to build sequence _heldContent.AddToHeld (currentObj); // Check if the held content should be released if (ShouldReleaseHeldContent ()) { ReleaseHeld (appendOutput); } } break; } index++; } } private void ReleaseHeld (Action appendOutput, AnsiResponseParserState newState = AnsiResponseParserState.Normal) { TryLastMinuteSequences (); foreach (object o in _heldContent.HeldToObjects ()) { appendOutput (o); } State = newState; _heldContent.ClearHeld (); } /// /// Checks current held chars against any sequences that have /// conflicts with longer sequences e.g. Esc as Alt sequences /// which can conflict if resolved earlier e.g. with EscOP ss3 /// sequences. /// protected void TryLastMinuteSequences () { lock (_lockState) { string? cur = _heldContent.HeldToString (); if (HandleKeyboard) { AnsiKeyboardParserPattern? pattern = _keyboardParser.IsKeyboard (cur, true); if (pattern != null) { RaiseKeyboardEvent (pattern, cur); _heldContent.ClearHeld (); return; } } // We have something totally unexpected, not a CSI and // still Esc+. So give last minute swallow chance if (cur!.Length >= 2 && cur [0] == ESCAPE) { // Maybe swallow anyway if user has custom delegate bool swallow = ShouldSwallowUnexpectedResponse (); if (swallow) { _heldContent.ClearHeld (); Logging.Trace ($"AnsiResponseParser last minute swallowed '{cur}'"); } } } } // Common response handler logic protected bool ShouldReleaseHeldContent () { lock (_lockState) { string? cur = _heldContent.HeldToString (); if (HandleMouse && IsMouse (cur)) { RaiseMouseEvent (cur); ResetState (); return false; } if (HandleKeyboard) { AnsiKeyboardParserPattern? pattern = _keyboardParser.IsKeyboard (cur); if (pattern != null) { RaiseKeyboardEvent (pattern, cur); ResetState (); return false; } } lock (_lockExpectedResponses) { // Look for an expected response for what is accumulated so far (since Esc) if (MatchResponse ( cur, _expectedResponses, true, true)) { return false; } // Also try looking for late requests - in which case we do not invoke but still swallow content to avoid corrupting downstream if (MatchResponse ( cur, _lateResponses, false, true)) { return false; } // Look for persistent requests if (MatchResponse ( cur, _persistentExpectations, true, false)) { return false; } } // Finally if it is a valid ansi response but not one we are expect (e.g. its mouse activity) // then we can release it back to input processing stream if (_knownTerminators.Contains (cur!.Last ()) && cur!.StartsWith (EscSeqUtils.CSI)) { // We have found a terminator so bail State = AnsiResponseParserState.Normal; // Maybe swallow anyway if user has custom delegate bool swallow = ShouldSwallowUnexpectedResponse (); if (swallow) { _heldContent.ClearHeld (); Logging.Trace ($"AnsiResponseParser swallowed '{cur}'"); // Do not send back to input stream return false; } // Do release back to input stream return true; } } return false; // Continue accumulating } private void RaiseMouseEvent (string? cur) { MouseEventArgs? ev = _mouseParser.ProcessMouseInput (cur); if (ev != null) { Mouse?.Invoke (this, ev); } } private bool IsMouse (string? cur) { return _mouseParser.IsMouse (cur); } protected void RaiseKeyboardEvent (AnsiKeyboardParserPattern pattern, string? cur) { Key? k = pattern.GetKey (cur); if (k is null) { Logging.Logger.LogError ($"Failed to determine a Key for given Keyboard escape sequence '{cur}'"); } else { Keyboard?.Invoke (this, k); } } /// /// /// When overriden in a derived class, indicates whether the unexpected response /// currently in should be released or swallowed. /// Use this to enable default event for escape codes. /// /// /// Note this is only called for complete responses. /// Based on /// /// /// protected abstract bool ShouldSwallowUnexpectedResponse (); private bool MatchResponse (string? cur, List collection, bool invokeCallback, bool removeExpectation) { // Check for expected responses AnsiResponseExpectation? matchingResponse = collection.FirstOrDefault (r => r.Matches (cur)); if (matchingResponse?.Response != null) { Logging.Trace ($"AnsiResponseParser processed '{cur}'"); if (invokeCallback) { matchingResponse.Response.Invoke (_heldContent); } ResetState (); if (removeExpectation) { collection.Remove (matchingResponse); } return true; } return false; } /// public void ExpectResponse (string? terminator, Action response, Action? abandoned, bool persistent) { lock (_lockExpectedResponses) { if (persistent) { _persistentExpectations.Add (new (terminator, h => response.Invoke (h.HeldToString ()), abandoned)); } else { _expectedResponses.Add (new (terminator, h => response.Invoke (h.HeldToString ()), abandoned)); } } } /// public bool IsExpecting (string? terminator) { lock (_lockExpectedResponses) { // If any of the new terminator matches any existing terminators characters it's a collision so true. return _expectedResponses.Any (r => r.Terminator!.Intersect (terminator!).Any ()); } } /// public void StopExpecting (string? terminator, bool persistent) { lock (_lockExpectedResponses) { if (persistent) { AnsiResponseExpectation [] removed = _persistentExpectations.Where (r => r.Matches (terminator)).ToArray (); foreach (AnsiResponseExpectation toRemove in removed) { _persistentExpectations.Remove (toRemove); toRemove.Abandoned?.Invoke (); } } else { AnsiResponseExpectation [] removed = _expectedResponses.Where (r => r.Terminator == terminator).ToArray (); foreach (AnsiResponseExpectation r in removed) { _expectedResponses.Remove (r); _lateResponses.Add (r); r.Abandoned?.Invoke (); } } } } } internal class AnsiResponseParser : AnsiResponseParserBase { public AnsiResponseParser () : base (new GenericHeld ()) { } /// public Func>, bool> UnexpectedResponseHandler { get; set; } = _ => false; public IEnumerable> ProcessInput (params Tuple [] input) { List> output = new (); ProcessInputBase ( i => input [i].Item1, i => input [i], c => AppendOutput (output, c), input.Length); return output; } private void AppendOutput (List> output, object c) { Tuple tuple = (Tuple)c; Logging.Trace ($"AnsiResponseParser releasing '{tuple.Item1}'"); output.Add (tuple); } public Tuple [] Release () { // Lock in case Release is called from different Thread from parse lock (_lockState) { TryLastMinuteSequences (); Tuple [] result = HeldToEnumerable ().ToArray (); ResetState (); return result; } } private IEnumerable> HeldToEnumerable () { return (IEnumerable>)_heldContent.HeldToObjects (); } /// /// 'Overload' for specifying an expectation that requires the metadata as well as characters. Has /// a unique name because otherwise most lamdas will give ambiguous overload errors. /// /// /// /// /// public void ExpectResponseT (string? terminator, Action>> response, Action? abandoned, bool persistent) { lock (_lockExpectedResponses) { if (persistent) { _persistentExpectations.Add (new (terminator, h => response.Invoke (HeldToEnumerable ()), abandoned)); } else { _expectedResponses.Add (new (terminator, h => response.Invoke (HeldToEnumerable ()), abandoned)); } } } /// protected override bool ShouldSwallowUnexpectedResponse () { return UnexpectedResponseHandler.Invoke (HeldToEnumerable ()); } } internal class AnsiResponseParser () : AnsiResponseParserBase (new StringHeld ()) { /// /// /// Delegate for handling unrecognized escape codes. Default behaviour /// is to return which simply releases the /// characters back to input stream for downstream processing. /// /// /// Implement a method to handle if you want and return if you want the /// keystrokes 'swallowed' (i.e. not returned to input stream). /// /// public Func UnknownResponseHandler { get; set; } = _ => false; 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 => AppendOutput (output, (char)c), input.Length); return output.ToString (); } private void AppendOutput (StringBuilder output, char c) { Logging.Trace ($"AnsiResponseParser releasing '{c}'"); output.Append (c); } public string? Release () { lock (_lockState) { TryLastMinuteSequences (); string? output = _heldContent.HeldToString (); ResetState (); return output; } } /// protected override bool ShouldSwallowUnexpectedResponse () { lock (_lockState) { return UnknownResponseHandler.Invoke (_heldContent.HeldToString ()); } } }