diff --git a/Terminal.Gui/Application/MainLoop.cs b/Terminal.Gui/Application/MainLoop.cs index ee4bba220c..bfd7429130 100644 --- a/Terminal.Gui/Application/MainLoop.cs +++ b/Terminal.Gui/Application/MainLoop.cs @@ -270,6 +270,8 @@ internal void RunIteration () } } + RunAnsiScheduler (); + MainLoopDriver.Iteration (); var runIdle = false; @@ -285,6 +287,11 @@ internal void RunIteration () } } + private void RunAnsiScheduler () + { + Application.Driver?.GetRequestScheduler ().RunSchedule (); + } + /// Stops the main loop driver and calls . Used only for unit tests. internal void Stop () { diff --git a/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs new file mode 100644 index 0000000000..3d4c9ebc3e --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs @@ -0,0 +1,67 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Describes an ongoing ANSI request sent to the console. +/// Use to handle the response +/// when console answers the request. +/// +public class AnsiEscapeSequenceRequest +{ + /// + /// Request to send e.g. see + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// + public required string Request { get; init; } + + /// + /// Invoked when the console responds with an ANSI response code that matches the + /// + /// + public Action ResponseReceived; + + /// + /// Invoked if the console fails to responds to the ANSI response code + /// + public Action? Abandoned; + + /// + /// + /// The terminator that uniquely identifies the type of response as responded + /// by the console. e.g. for + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// the terminator is + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Terminator + /// + /// . + /// + /// + /// After sending a request, the first response with matching terminator will be matched + /// to the oldest outstanding request. + /// + /// + public required string Terminator { get; init; } + + /// + /// Sends the to the raw output stream of the current . + /// Only call this method from the main UI thread. You should use if + /// sending many requests. + /// + public void Send () { Application.Driver?.RawWrite (Request); } + + + /// + /// The value expected in the response e.g. + /// + /// EscSeqUtils.CSI_ReportTerminalSizeInChars.Value + /// + /// which will have a 't' as terminator but also other different request may return the same terminator with a + /// different value. + /// + public string? Value { get; init; } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs new file mode 100644 index 0000000000..12a2c270fb --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs @@ -0,0 +1,232 @@ +#nullable enable +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// +/// Manages made to an . +/// Ensures there are not 2+ outstanding requests with the same terminator, throttles request sends +/// to prevent console becoming unresponsive and handles evicting ignored requests (no reply from +/// terminal). +/// +internal class AnsiRequestScheduler +{ + private readonly IAnsiResponseParser _parser; + + /// + /// Function for returning the current time. Use in unit tests to + /// ensure repeatable tests. + /// + internal Func Now { get; set; } + + private readonly HashSet> _queuedRequests = new (); + + internal IReadOnlyCollection QueuedRequests => _queuedRequests.Select (r => r.Item1).ToList (); + + /// + /// + /// Dictionary where key is ansi request terminator and value is when we last sent a request for + /// this terminator. Combined with this prevents hammering the console + /// with too many requests in sequence which can cause console to freeze as there is no space for + /// regular screen drawing / mouse events etc to come in. + /// + /// + /// When user exceeds the throttle, new requests accumulate in (i.e. remain + /// queued). + /// + /// + private readonly ConcurrentDictionary _lastSend = new (); + + /// + /// Number of milliseconds after sending a request that we allow + /// another request to go out. + /// + private readonly TimeSpan _throttle = TimeSpan.FromMilliseconds (100); + + private readonly TimeSpan _runScheduleThrottle = TimeSpan.FromMilliseconds (100); + + /// + /// If console has not responded to a request after this period of time, we assume that it is never going + /// to respond. Only affects when we try to send a new request with the same terminator - at which point + /// we tell the parser to stop expecting the old request and start expecting the new request. + /// + private readonly TimeSpan _staleTimeout = TimeSpan.FromSeconds (1); + + private readonly DateTime _lastRun; + + public AnsiRequestScheduler (IAnsiResponseParser parser, Func? now = null) + { + _parser = parser; + Now = now ?? (() => DateTime.Now); + _lastRun = Now (); + } + + /// + /// Sends the immediately or queues it if there is already + /// an outstanding request for the given . + /// + /// + /// if request was sent immediately. if it was queued. + public bool SendOrSchedule (AnsiEscapeSequenceRequest request) + { + return SendOrSchedule (request, true); + } + private bool SendOrSchedule (AnsiEscapeSequenceRequest request,bool addToQueue) + { + if (CanSend (request, out ReasonCannotSend reason)) + { + Send (request); + + return true; + } + + if (reason == ReasonCannotSend.OutstandingRequest) + { + // If we can evict an old request (no response from terminal after ages) + if (EvictStaleRequests (request.Terminator)) + { + // Try again after evicting + if (CanSend (request, out _)) + { + Send (request); + + return true; + } + } + } + + if (addToQueue) + { + _queuedRequests.Add (Tuple.Create (request, Now ())); + } + + return false; + } + + private void EvictStaleRequests () + { + foreach (var stale in _lastSend.Where (v => IsStale (v.Value)).Select (k => k.Key)) + { + EvictStaleRequests (stale); + } + } + + private bool IsStale (DateTime dt) => Now () - dt > _staleTimeout; + + /// + /// Looks to see if the last time we sent + /// is a long time ago. If so we assume that we will never get a response and + /// can proceed with a new request for this terminator (returning ). + /// + /// + /// + private bool EvictStaleRequests (string withTerminator) + { + if (_lastSend.TryGetValue (withTerminator, out DateTime dt)) + { + if (IsStale (dt)) + { + _parser.StopExpecting (withTerminator, false); + + return true; + } + } + + return false; + } + + /// + /// Identifies and runs any that can be sent based on the + /// current outstanding requests of the parser. + /// + /// + /// Repeated requests to run the schedule over short period of time will be ignored. + /// Pass to override this behaviour and force evaluation of outstanding requests. + /// + /// + /// if a request was found and run. + /// if no outstanding requests or all have existing outstanding requests underway in parser. + /// + public bool RunSchedule (bool force = false) + { + if (!force && Now () - _lastRun < _runScheduleThrottle) + { + return false; + } + + // Get oldest request + Tuple? opportunity = _queuedRequests.MinBy (r=>r.Item2); + + if (opportunity != null) + { + // Give it another go + if (SendOrSchedule (opportunity.Item1, false)) + { + _queuedRequests.Remove (opportunity); + return true; + } + } + + EvictStaleRequests (); + + return false; + } + + + private void Send (AnsiEscapeSequenceRequest r) + { + _lastSend.AddOrUpdate (r.Terminator, _ => Now (), (_, _) => Now ()); + _parser.ExpectResponse (r.Terminator, r.ResponseReceived, r.Abandoned, false); + r.Send (); + } + + private bool CanSend (AnsiEscapeSequenceRequest r, out ReasonCannotSend reason) + { + if (ShouldThrottle (r)) + { + reason = ReasonCannotSend.TooManyRequests; + + return false; + } + + if (_parser.IsExpecting (r.Terminator)) + { + reason = ReasonCannotSend.OutstandingRequest; + + return false; + } + + reason = default (ReasonCannotSend); + + return true; + } + + private bool ShouldThrottle (AnsiEscapeSequenceRequest r) + { + if (_lastSend.TryGetValue (r.Terminator, out DateTime value)) + { + return Now () - value < _throttle; + } + + return false; + } +} + +internal enum ReasonCannotSend +{ + /// + /// No reason given. + /// + None = 0, + + /// + /// The parser is already waiting for a request to complete with the given terminator. + /// + OutstandingRequest, + + /// + /// There have been too many requests sent recently, new requests will be put into + /// queue to prevent console becoming unresponsive. + /// + TooManyRequests +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseExpectation.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseExpectation.cs new file mode 100644 index 0000000000..00aa1a9512 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseExpectation.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Terminal.Gui; + +internal record AnsiResponseExpectation (string Terminator, Action Response, Action? Abandoned) +{ + public bool Matches (string cur) { return cur.EndsWith (Terminator); } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs new file mode 100644 index 0000000000..027dba0552 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs @@ -0,0 +1,442 @@ +#nullable enable + +namespace Terminal.Gui; + +internal abstract class AnsiResponseParserBase : IAnsiResponseParser +{ + protected object lockExpectedResponses = new (); + + protected object lockState = new (); + + /// + /// Responses we are expecting to come in. + /// + protected readonly List expectedResponses = new (); + + /// + /// Collection of responses that we . + /// + protected readonly List lateResponses = new (); + + /// + /// 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 = new (); + + private AnsiResponseParserState _state = AnsiResponseParserState.Normal; + + /// + public AnsiResponseParserState State + { + get => _state; + protected set + { + StateChangedAt = DateTime.Now; + _state = value; + } + } + + protected readonly IHeld heldContent; + + /// + /// 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 = new ( + new [] + { + '@', '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) { this.heldContent = heldContent; } + + protected void ResetState () + { + State = AnsiResponseParserState.Normal; + 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 == '\x1B'; + + switch (State) + { + case AnsiResponseParserState.Normal: + if (isEscape) + { + // Escape character detected, move to ExpectingBracket state + State = AnsiResponseParserState.ExpectingBracket; + heldContent.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); + heldContent.AddToHeld (currentObj); // Hold the new escape + } + else if (currentChar == '[') + { + // Detected '[', transition to InResponse state + State = AnsiResponseParserState.InResponse; + heldContent.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: + 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) + { + foreach (object o in heldContent.HeldToObjects ()) + { + appendOutput (o); + } + + State = newState; + heldContent.ClearHeld (); + } + + // Common response handler logic + protected bool ShouldReleaseHeldContent () + { + string cur = heldContent.HeldToString (); + + 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 (); + + // Do not send back to input stream + return false; + } + + // Do release back to input stream + return true; + } + + return false; // Continue accumulating + } + + /// + /// + /// 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) + { + 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 (var 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 => output.Add ((Tuple)c), + input.Length); + + return output; + } + + public IEnumerable> Release () + { + // Lock in case Release is called from different Thread from parse + lock (lockState) + { + foreach (Tuple h in HeldToEnumerable ()) + { + yield return h; + } + + ResetState (); + } + } + + 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 +{ + /// + /// + /// 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 AnsiResponseParser () : base (new StringHeld ()) { } + + 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 () + { + lock (lockState) + { + string output = heldContent.HeldToString (); + ResetState (); + + return output; + } + } + + /// + protected override bool ShouldSwallowUnexpectedResponse () { return UnknownResponseHandler.Invoke (heldContent.HeldToString ()); } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs new file mode 100644 index 0000000000..ffd8d46891 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Implementation of for +/// +/// +internal class GenericHeld : IHeld +{ + private readonly List> held = new (); + + public void ClearHeld () { held.Clear (); } + + public string HeldToString () { return new (held.Select (h => h.Item1).ToArray ()); } + + public IEnumerable HeldToObjects () { return held; } + + public void AddToHeld (object o) { held.Add ((Tuple)o); } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs new file mode 100644 index 0000000000..bc70142276 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs @@ -0,0 +1,54 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// When implemented in a derived class, allows watching an input stream of characters +/// (i.e. console input) for ANSI response sequences. +/// +public interface IAnsiResponseParser +{ + /// + /// Current state of the parser based on what sequence of characters it has + /// read from the console input stream. + /// + AnsiResponseParserState State { get; } + + /// + /// Notifies the parser that you are expecting a response to come in + /// with the given (i.e. because you have + /// sent an ANSI request out). + /// + /// The terminator you expect to see on response. + /// Callback to invoke when the response is seen in console input. + /// + /// + /// if you want this to persist permanently + /// and be raised for every event matching the . + /// + /// + /// If trying to register a persistent request for a terminator + /// that already has one. + /// exists. + /// + void ExpectResponse (string terminator, Action response,Action? abandoned, bool persistent); + + /// + /// Returns true if there is an existing expectation (i.e. we are waiting a response + /// from console) for the given . + /// + /// + /// + bool IsExpecting (string terminator); + + /// + /// Removes callback and expectation that we will get a response for the + /// given . Use to give up on very old + /// requests e.g. if you want to send a different one with the same terminator. + /// + /// + /// + /// if you want to remove a persistent + /// request listener. + /// + void StopExpecting (string requestTerminator, bool persistent); +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs new file mode 100644 index 0000000000..ab23f477fd --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Describes a sequence of chars (and optionally T metadata) accumulated +/// by an +/// +internal interface IHeld +{ + /// + /// Clears all held objects + /// + void ClearHeld (); + + /// + /// Returns string representation of the held objects + /// + /// + string HeldToString (); + + /// + /// Returns the collection objects directly e.g. + /// or + metadata T + /// + /// + IEnumerable HeldToObjects (); + + /// + /// Adds the given object to the collection. + /// + /// + void AddToHeld (object o); +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs new file mode 100644 index 0000000000..4a03bc3b5e --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs @@ -0,0 +1,18 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Implementation of for +/// +internal class StringHeld : IHeld +{ + private readonly StringBuilder held = new (); + + public void ClearHeld () { held.Clear (); } + + public string HeldToString () { return held.ToString (); } + + public IEnumerable HeldToObjects () { return held.ToString ().Select (c => (object)c); } + + public void AddToHeld (object o) { held.Append ((char)o); } +} diff --git a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs index 7d6de3834f..a6c94e4786 100644 --- a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs @@ -15,6 +15,12 @@ namespace Terminal.Gui; /// public abstract class ConsoleDriver { + + /// + /// How long after Esc has been pressed before we give up on getting an Ansi escape sequence + /// + internal TimeSpan EscTimeout = TimeSpan.FromMilliseconds (50); + // As performance is a concern, we keep track of the dirty lines and only refresh those. // This is in addition to the dirty flag on each cell. internal bool []? _dirtyLines; @@ -609,6 +615,32 @@ public void OnMouseEvent (MouseEventArgs a) public abstract void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl); #endregion + + private AnsiRequestScheduler? _scheduler; + + /// + /// Queues the given for execution + /// + /// + public void QueueAnsiRequest (AnsiEscapeSequenceRequest request) + { + GetRequestScheduler ().SendOrSchedule (request); + } + + internal abstract IAnsiResponseParser GetParser (); + + internal AnsiRequestScheduler GetRequestScheduler () + { + // Lazy initialization because GetParser is virtual + return _scheduler ??= new (GetParser ()); + } + + /// + /// Writes the given directly to the console (rather than the output + /// draw buffer). + /// + /// + internal abstract void RawWrite (string str); } /// diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs index 88005861ad..7be9c79d13 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs @@ -193,6 +193,12 @@ public void StopReportingMouseMoves () } } + /// + internal override void RawWrite (string str) + { + Console.Out.Write (str); + } + public override void Suspend () { StopReportingMouseMoves (); @@ -584,6 +590,10 @@ internal override MainLoop Init () return new MainLoop (_mainLoopDriver); } + private readonly AnsiResponseParser _parser = new (); + /// + internal override IAnsiResponseParser GetParser () => _parser; + internal void ProcessInput () { int wch; @@ -882,6 +892,14 @@ ref ConsoleKeyInfo [] cki } else { + if (cki != null) + { + foreach (var c in cki) + { + _parser.ProcessInput (c.KeyChar.ToString()); + } + } + k = ConsoleKeyMapping.MapConsoleKeyInfoToKeyCode (consoleKeyInfo); keyEventArgs = new Key (k); OnKeyDown (keyEventArgs); diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 6330c33702..57460d6dba 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -970,6 +970,18 @@ public static ConsoleKeyInfo MapConsoleKeyInfo (ConsoleKeyInfo consoleKeyInfo) break; case uint n when n > 0 && n <= KeyEsc: + if (n == KeyEsc) + { + key= ConsoleKey.Escape; + + newConsoleKeyInfo = new ConsoleKeyInfo ( + consoleKeyInfo.KeyChar, + key, + (consoleKeyInfo.Modifiers & ConsoleModifiers.Shift) != 0, + (consoleKeyInfo.Modifiers & ConsoleModifiers.Alt) != 0, + (consoleKeyInfo.Modifiers & ConsoleModifiers.Control) != 0); + } + else if (consoleKeyInfo.Key == 0 && consoleKeyInfo.KeyChar == '\r') { key = ConsoleKey.Enter; @@ -1317,7 +1329,7 @@ public enum DECSCUSR_Style /// ESC [ ? 6 n - Request Cursor Position Report (?) (DECXCPR) /// https://terminalguide.namepad.de/seq/csi_sn__p-6/ /// - public static readonly string CSI_RequestCursorPositionReport = CSI + "?6n"; + public static readonly AnsiEscapeSequenceRequest CSI_RequestCursorPositionReport = new () { Request = CSI + "?6n", Terminator = "R" }; /// /// The terminal reply to . ESC [ ? (y) ; (x) R @@ -1342,13 +1354,13 @@ public enum DECSCUSR_Style /// 32 = Text macros /// 42 = ISO Latin-2 character set /// - public static readonly string CSI_SendDeviceAttributes = CSI + "0c"; + public static readonly AnsiEscapeSequenceRequest CSI_SendDeviceAttributes = new () { Request = CSI + "0c", Terminator = "c" }; /// /// ESC [ > 0 c - Send Device Attributes (Secondary DA) /// Windows Terminal v1.18+ emits: "\x1b[>0;10;1c" (vt100, firmware version 1.0, vt220) /// - public static readonly string CSI_SendDeviceAttributes2 = CSI + ">0c"; + public static readonly AnsiEscapeSequenceRequest CSI_SendDeviceAttributes2 = new () { Request = CSI + ">0c", Terminator = "c" }; /// /// The terminator indicating a reply to or @@ -1356,8 +1368,6 @@ public enum DECSCUSR_Style /// public const string CSI_ReportDeviceAttributes_Terminator = "c"; - /* - TODO: depends on https://github.com/gui-cs/Terminal.Gui/pull/3768 /// /// CSI 16 t - Request sixel resolution (width and height in pixels) /// @@ -1367,13 +1377,13 @@ public enum DECSCUSR_Style /// CSI 14 t - Request window size in pixels (width x height) /// public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" }; - */ /// /// CSI 1 8 t | yes | yes | yes | report window size in chars /// https://terminalguide.namepad.de/seq/csi_st-18/ /// - public static readonly string CSI_ReportTerminalSizeInChars = CSI + "18t"; + public static readonly AnsiEscapeSequenceRequest CSI_ReportTerminalSizeInChars = new () { Request = CSI + "18t", Terminator = "t", Value = "8" }; + /// /// The terminator indicating a reply to : ESC [ 8 ; height ; width t diff --git a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs index 73c12959f4..bb115b9fb8 100644 --- a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs @@ -392,6 +392,14 @@ public override void SendKeys (char keyChar, ConsoleKey key, bool shift, bool al MockKeyPressedHandler (new ConsoleKeyInfo (keyChar, key, shift, alt, control)); } + private AnsiResponseParser _parser = new (); + + /// + internal override IAnsiResponseParser GetParser () => _parser; + + /// + internal override void RawWrite (string str) { } + public void SetBufferSize (int width, int height) { FakeConsole.SetBufferSize (width, height); diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index cab7d3e6bf..3d16ddd594 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -2,6 +2,7 @@ // NetDriver.cs: The System.Console-based .NET driver, works on Windows and Unix, but is not particularly efficient. // +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; @@ -134,71 +135,68 @@ public void Cleanup () internal class NetEvents : IDisposable { - private readonly ManualResetEventSlim _inputReady = new (false); - private CancellationTokenSource _inputReadyCancellationTokenSource; - private readonly ManualResetEventSlim _waitForStart = new (false); + private readonly CancellationTokenSource _netEventsDisposed = new CancellationTokenSource (); //CancellationTokenSource _waitForStartCancellationTokenSource; private readonly ManualResetEventSlim _winChange = new (false); - private readonly Queue _inputQueue = new (); + private readonly BlockingCollection _inputQueue = new (new ConcurrentQueue ()); private readonly ConsoleDriver _consoleDriver; - private ConsoleKeyInfo [] _cki; - private bool _isEscSeq; -#if PROCESS_REQUEST - bool _neededProcessRequest; -#endif + public EscSeqRequests EscSeqRequests { get; } = new (); + public AnsiResponseParser Parser { get; private set; } = new (); + public NetEvents (ConsoleDriver consoleDriver) { _consoleDriver = consoleDriver ?? throw new ArgumentNullException (nameof (consoleDriver)); - _inputReadyCancellationTokenSource = new CancellationTokenSource (); - Task.Run (ProcessInputQueue, _inputReadyCancellationTokenSource.Token); + Task.Run (() => + { + try + { + ProcessInputQueue (); + } + catch (OperationCanceledException) + { } + }, _netEventsDisposed.Token); + + Task.Run (()=>{ + + try + { + CheckWindowSizeChange(); + } + catch (OperationCanceledException) + { }}, _netEventsDisposed.Token); - Task.Run (CheckWindowSizeChange, _inputReadyCancellationTokenSource.Token); + Parser.UnexpectedResponseHandler = ProcessRequestResponse; } + public InputResult? DequeueInput () { - while (_inputReadyCancellationTokenSource != null - && !_inputReadyCancellationTokenSource.Token.IsCancellationRequested) + while (!_netEventsDisposed.Token.IsCancellationRequested) { - _waitForStart.Set (); _winChange.Set (); try { - if (!_inputReadyCancellationTokenSource.Token.IsCancellationRequested) + if (_inputQueue.TryTake (out var item, -1, _netEventsDisposed.Token)) { - if (_inputQueue.Count == 0) - { - _inputReady.Wait (_inputReadyCancellationTokenSource.Token); - } + return item; } } catch (OperationCanceledException) { return null; } - finally - { - _inputReady.Reset (); - } -#if PROCESS_REQUEST - _neededProcessRequest = false; -#endif - if (_inputQueue.Count > 0) - { - return _inputQueue.Dequeue (); - } } return null; } - private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellationToken, bool intercept = true) + private ConsoleKeyInfo ReadConsoleKeyInfo ( bool intercept = true) { // if there is a key available, return it without waiting // (or dispatching work to the thread queue) @@ -207,9 +205,14 @@ private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellation return Console.ReadKey (intercept); } - while (!cancellationToken.IsCancellationRequested) + while (!_netEventsDisposed.IsCancellationRequested) { - Task.Delay (100, cancellationToken).Wait (cancellationToken); + Task.Delay (100, _netEventsDisposed.Token).Wait (_netEventsDisposed.Token); + + foreach (var k in ShouldRelease ()) + { + ProcessMapConsoleKeyInfo (k); + } if (Console.KeyAvailable) { @@ -217,124 +220,62 @@ private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellation } } - cancellationToken.ThrowIfCancellationRequested (); + _netEventsDisposed.Token.ThrowIfCancellationRequested (); return default (ConsoleKeyInfo); } - private void ProcessInputQueue () + public IEnumerable ShouldRelease () { - while (_inputReadyCancellationTokenSource is { IsCancellationRequested: false }) + if (Parser.State == AnsiResponseParserState.ExpectingBracket && + DateTime.Now - Parser.StateChangedAt > _consoleDriver.EscTimeout) { - try - { - _waitForStart.Wait (_inputReadyCancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - return; - } + return Parser.Release ().Select (o => o.Item2); + } - _waitForStart.Reset (); + return []; + } + private void ProcessInputQueue () + { + while (!_netEventsDisposed.IsCancellationRequested) + { if (_inputQueue.Count == 0) { - ConsoleKey key = 0; - ConsoleModifiers mod = 0; - ConsoleKeyInfo newConsoleKeyInfo = default; - - while (_inputReadyCancellationTokenSource is { IsCancellationRequested: false }) + while (!_netEventsDisposed.IsCancellationRequested) { ConsoleKeyInfo consoleKeyInfo; - try - { - consoleKeyInfo = ReadConsoleKeyInfo (_inputReadyCancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - return; - } - - if ((consoleKeyInfo.KeyChar == (char)KeyCode.Esc && !_isEscSeq) - || (consoleKeyInfo.KeyChar != (char)KeyCode.Esc && _isEscSeq)) - { - if (_cki is null && consoleKeyInfo.KeyChar != (char)KeyCode.Esc && _isEscSeq) - { - _cki = EscSeqUtils.ResizeArray ( - new ConsoleKeyInfo ( - (char)KeyCode.Esc, - 0, - false, - false, - false - ), - _cki - ); - } - - _isEscSeq = true; - newConsoleKeyInfo = consoleKeyInfo; - _cki = EscSeqUtils.ResizeArray (consoleKeyInfo, _cki); + consoleKeyInfo = ReadConsoleKeyInfo (); - if (Console.KeyAvailable) - { - continue; - } - - ProcessRequestResponse (ref newConsoleKeyInfo, ref key, _cki, ref mod); - _cki = null; - _isEscSeq = false; - - break; - } - - if (consoleKeyInfo.KeyChar == (char)KeyCode.Esc && _isEscSeq && _cki is { }) + // Parse + foreach (var k in Parser.ProcessInput (Tuple.Create (consoleKeyInfo.KeyChar, consoleKeyInfo))) { - ProcessRequestResponse (ref newConsoleKeyInfo, ref key, _cki, ref mod); - _cki = null; - - if (Console.KeyAvailable) - { - _cki = EscSeqUtils.ResizeArray (consoleKeyInfo, _cki); - } - else - { - ProcessMapConsoleKeyInfo (consoleKeyInfo); - } - - break; + ProcessMapConsoleKeyInfo (k.Item2); } - - ProcessMapConsoleKeyInfo (consoleKeyInfo); - - break; } } - - _inputReady.Set (); } + } - void ProcessMapConsoleKeyInfo (ConsoleKeyInfo consoleKeyInfo) - { - _inputQueue.Enqueue ( - new InputResult - { - EventType = EventType.Key, ConsoleKeyInfo = EscSeqUtils.MapConsoleKeyInfo (consoleKeyInfo) - } - ); - _isEscSeq = false; - } + void ProcessMapConsoleKeyInfo (ConsoleKeyInfo consoleKeyInfo) + { + _inputQueue.Add ( + new InputResult + { + EventType = EventType.Key, ConsoleKeyInfo = EscSeqUtils.MapConsoleKeyInfo (consoleKeyInfo) + } + ); } private void CheckWindowSizeChange () { - void RequestWindowSize (CancellationToken cancellationToken) + void RequestWindowSize () { - while (!cancellationToken.IsCancellationRequested) + while (!_netEventsDisposed.IsCancellationRequested) { // Wait for a while then check if screen has changed sizes - Task.Delay (500, cancellationToken).Wait (cancellationToken); + Task.Delay (500, _netEventsDisposed.Token).Wait (_netEventsDisposed.Token); int buffHeight, buffWidth; @@ -360,24 +301,22 @@ void RequestWindowSize (CancellationToken cancellationToken) } } - cancellationToken.ThrowIfCancellationRequested (); + _netEventsDisposed.Token.ThrowIfCancellationRequested (); } - while (_inputReadyCancellationTokenSource is { IsCancellationRequested: false }) + while (!_netEventsDisposed.IsCancellationRequested) { try { - _winChange.Wait (_inputReadyCancellationTokenSource.Token); + _winChange.Wait (_netEventsDisposed.Token); _winChange.Reset (); - RequestWindowSize (_inputReadyCancellationTokenSource.Token); + RequestWindowSize (); } catch (OperationCanceledException) { return; } - - _inputReady.Set (); } } @@ -397,7 +336,7 @@ private bool EnqueueWindowSizeEvent (int winHeight, int winWidth, int buffHeight int w = Math.Max (winWidth, 0); int h = Math.Max (winHeight, 0); - _inputQueue.Enqueue ( + _inputQueue.Add ( new InputResult { EventType = EventType.WindowSize, WindowSizeEvent = new WindowSizeEvent { Size = new (w, h) } @@ -407,6 +346,19 @@ private bool EnqueueWindowSizeEvent (int winHeight, int winWidth, int buffHeight return true; } + private bool ProcessRequestResponse (IEnumerable> obj) + { + // Added for signature compatibility with existing method, not sure what they are even for. + ConsoleKeyInfo newConsoleKeyInfo = default; + ConsoleKey key = default; + ConsoleModifiers mod = default; + + ProcessRequestResponse (ref newConsoleKeyInfo, ref key, obj.Select (v=>v.Item2).ToArray (),ref mod); + + // Handled + return true; + } + // Process a CSI sequence received by the driver (key pressed, mouse event, or request/response event) private void ProcessRequestResponse ( ref ConsoleKeyInfo newConsoleKeyInfo, @@ -415,6 +367,7 @@ private void ProcessRequestResponse ( ref ConsoleModifiers mod ) { + // isMouse is true if it's CSI<, false otherwise EscSeqUtils.DecodeEscSeq ( EscSeqRequests, @@ -603,7 +556,7 @@ private void HandleRequestResponseEvent (string c1Control, string code, string [ var eventType = EventType.WindowPosition; var winPositionEv = new WindowPositionEvent { CursorPosition = point }; - _inputQueue.Enqueue ( + _inputQueue.Add ( new InputResult { EventType = eventType, WindowPositionEvent = winPositionEv } ); } @@ -638,8 +591,6 @@ private void HandleRequestResponseEvent (string c1Control, string code, string [ break; } - - _inputReady.Set (); } private void EnqueueRequestResponseEvent (string c1Control, string code, string [] values, string terminating) @@ -647,7 +598,7 @@ private void EnqueueRequestResponseEvent (string c1Control, string code, string var eventType = EventType.RequestResponse; var requestRespEv = new RequestResponseEvent { ResultTuple = (c1Control, code, values, terminating) }; - _inputQueue.Enqueue ( + _inputQueue.Add ( new InputResult { EventType = eventType, RequestResponseEvent = requestRespEv } ); } @@ -656,11 +607,9 @@ private void HandleMouseEvent (MouseButtonState buttonState, Point pos) { var mouseEvent = new MouseEvent { Position = pos, ButtonState = buttonState }; - _inputQueue.Enqueue ( + _inputQueue.Add ( new InputResult { EventType = EventType.Mouse, MouseEvent = mouseEvent } ); - - _inputReady.Set (); } public enum EventType @@ -773,14 +722,13 @@ private void HandleKeyboardEvent (ConsoleKeyInfo cki) { var inputResult = new InputResult { EventType = EventType.Key, ConsoleKeyInfo = cki }; - _inputQueue.Enqueue (inputResult); + _inputQueue.Add (inputResult); } public void Dispose () { - _inputReadyCancellationTokenSource?.Cancel (); - _inputReadyCancellationTokenSource?.Dispose (); - _inputReadyCancellationTokenSource = null; + _netEventsDisposed?.Cancel (); + _netEventsDisposed?.Dispose (); try { @@ -1045,6 +993,15 @@ void WriteToConsole (StringBuilder output, ref int lastCol, int row, ref int out } } + /// + internal override IAnsiResponseParser GetParser () => _mainLoopDriver._netEvents.Parser; + + /// + internal override void RawWrite (string str) + { + Console.Write (str); + } + internal override void End () { if (IsWinPlatform) @@ -1689,11 +1646,11 @@ internal class NetMainLoop : IMainLoopDriver /// Invoked when a Key is pressed. internal Action ProcessInput; - private readonly ManualResetEventSlim _eventReady = new (false); private readonly CancellationTokenSource _inputHandlerTokenSource = new (); - private readonly Queue _resultQueue = new (); + // Wrap ConcurrentQueue in a BlockingCollection to enable blocking with timeout + private readonly BlockingCollection _resultQueue = new (new ConcurrentQueue ()); + private readonly ManualResetEventSlim _waitForProbe = new (false); - private readonly CancellationTokenSource _eventReadyTokenSource = new (); private MainLoop _mainLoop; /// Initializes the class with the console driver. @@ -1716,50 +1673,25 @@ void IMainLoopDriver.Setup (MainLoop mainLoop) Task.Run (NetInputHandler, _inputHandlerTokenSource.Token); } - void IMainLoopDriver.Wakeup () { _eventReady.Set (); } + void IMainLoopDriver.Wakeup () { } bool IMainLoopDriver.EventsPending () { _waitForProbe.Set (); - if (_mainLoop.CheckTimersAndIdleHandlers (out int waitTimeout)) - { - return true; - } - - try - { - if (!_eventReadyTokenSource.IsCancellationRequested) - { - // Note: ManualResetEventSlim.Wait will wait indefinitely if the timeout is -1. The timeout is -1 when there - // are no timers, but there IS an idle handler waiting. - _eventReady.Wait (waitTimeout, _eventReadyTokenSource.Token); - } - } - catch (OperationCanceledException) + if (_mainLoop.CheckTimersAndIdleHandlers (out _)) { return true; } - finally - { - _eventReady.Reset (); - } - _eventReadyTokenSource.Token.ThrowIfCancellationRequested (); - - if (!_eventReadyTokenSource.IsCancellationRequested) - { - return _resultQueue.Count > 0 || _mainLoop.CheckTimersAndIdleHandlers (out _); - } - - return true; + return _resultQueue.Count > 0 || _mainLoop.CheckTimersAndIdleHandlers (out _); } void IMainLoopDriver.Iteration () { - while (_resultQueue.Count > 0) + while (_resultQueue.TryTake (out InputResult v)) { - ProcessInput?.Invoke (_resultQueue.Dequeue ().Value); + ProcessInput?.Invoke (v); } } @@ -1767,12 +1699,7 @@ void IMainLoopDriver.TearDown () { _inputHandlerTokenSource?.Cancel (); _inputHandlerTokenSource?.Dispose (); - _eventReadyTokenSource?.Cancel (); - _eventReadyTokenSource?.Dispose (); - _eventReady?.Dispose (); - - _resultQueue?.Clear (); _waitForProbe?.Dispose (); _netEvents?.Dispose (); _netEvents = null; @@ -1810,19 +1737,11 @@ private void NetInputHandler () _inputHandlerTokenSource.Token.ThrowIfCancellationRequested (); - if (_resultQueue.Count == 0) - { - _resultQueue.Enqueue (_netEvents.DequeueInput ()); - } - - while (_resultQueue.Count > 0 && _resultQueue.Peek () is null) - { - _resultQueue.Dequeue (); - } + var input = _netEvents.DequeueInput (); - if (_resultQueue.Count > 0) + if (input.HasValue) { - _eventReady.Set (); + _resultQueue.Add (input.Value); } } } diff --git a/Terminal.Gui/ConsoleDrivers/ParserState.cs b/Terminal.Gui/ConsoleDrivers/ParserState.cs new file mode 100644 index 0000000000..934b6eb3eb --- /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 0b15245427..8782667eac 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -15,10 +15,12 @@ #define HACK_CHECK_WINCHANGED +using System.Collections; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; +using Terminal.Gui.ConsoleDrivers; using static Terminal.Gui.ConsoleDrivers.ConsoleKeyMapping; using static Terminal.Gui.SpinnerStyle; @@ -1177,6 +1179,11 @@ public override void SendKeys (char keyChar, ConsoleKey key, bool shift, bool al } } + /// + internal override IAnsiResponseParser GetParser () => _parser; + + /// + internal override void RawWrite (string str) => WinConsole?.WriteANSI (str); #region Not Implemented @@ -1458,11 +1465,23 @@ internal override MainLoop Init () #endif WinConsole?.SetInitialCursorVisibility (); + return new MainLoop (_mainLoopDriver); } + private AnsiResponseParser _parser = new (); + internal void ProcessInput (WindowsConsole.InputRecord inputEvent) { + foreach (var e in Parse (inputEvent)) + { + ProcessInputAfterParsing (e); + } + } + + internal void ProcessInputAfterParsing (WindowsConsole.InputRecord inputEvent) + { + switch (inputEvent.EventType) { case WindowsConsole.EventType.Key: @@ -1485,15 +1504,9 @@ internal void ProcessInput (WindowsConsole.InputRecord inputEvent) break; } - if (inputEvent.KeyEvent.bKeyDown) - { - // Avoid sending repeat key down events - OnKeyDown (new Key (map)); - } - else - { - OnKeyUp (new Key (map)); - } + // This follows convention in NetDriver + OnKeyDown (new Key (map)); + OnKeyUp (new Key (map)); break; @@ -1535,6 +1548,43 @@ internal void ProcessInput (WindowsConsole.InputRecord inputEvent) } } + private IEnumerable Parse (WindowsConsole.InputRecord inputEvent) + { + if (inputEvent.EventType != WindowsConsole.EventType.Key) + { + yield return inputEvent; + yield break; + } + + // Swallow key up events - they are unreliable + if (!inputEvent.KeyEvent.bKeyDown) + { + yield break; + } + + foreach (var i in ShouldRelease ()) + { + yield return i; + } + + foreach (Tuple output in + _parser.ProcessInput (Tuple.Create (inputEvent.KeyEvent.UnicodeChar, inputEvent))) + { + yield return output.Item2; + } + } + + public IEnumerable ShouldRelease () + { + if (_parser.State == AnsiResponseParserState.ExpectingBracket && + DateTime.Now - _parser.StateChangedAt > EscTimeout) + { + return _parser.Release ().Select (o => o.Item2); + } + + return []; + } + #if HACK_CHECK_WINCHANGED private void ChangeWin (object s, SizeChangedEventArgs e) { @@ -2238,6 +2288,11 @@ bool IMainLoopDriver.EventsPending () void IMainLoopDriver.Iteration () { + foreach(var i in ((WindowsDriver)_consoleDriver).ShouldRelease()) + { + ((WindowsDriver)_consoleDriver).ProcessInput (i); + } + while (_resultQueue.Count > 0) { WindowsConsole.InputRecord [] inputRecords = _resultQueue.Dequeue (); diff --git a/Terminal.Gui/Drawing/AssumeSupportDetector.cs b/Terminal.Gui/Drawing/AssumeSupportDetector.cs deleted file mode 100644 index 46081714ac..0000000000 --- a/Terminal.Gui/Drawing/AssumeSupportDetector.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Terminal.Gui; - -/// -/// Implementation of that assumes best -/// case scenario (full support including transparency with 10x20 resolution). -/// -public class AssumeSupportDetector : ISixelSupportDetector -{ - /// - public SixelSupportResult Detect () - { - return new() - { - IsSupported = true, - MaxPaletteColors = 256, - Resolution = new (10, 20), - SupportsTransparency = true - }; - } -} diff --git a/Terminal.Gui/Drawing/ISixelSupportDetector.cs b/Terminal.Gui/Drawing/ISixelSupportDetector.cs deleted file mode 100644 index eb0bb9f120..0000000000 --- a/Terminal.Gui/Drawing/ISixelSupportDetector.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Terminal.Gui; - -/// -/// Interface for detecting sixel support. Either through -/// ansi requests to terminal or config file etc. -/// -public interface ISixelSupportDetector -{ - /// - /// Gets the supported sixel state e.g. by sending Ansi escape sequences - /// or from a config file etc. - /// - /// Description of sixel support. - public SixelSupportResult Detect (); -} diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index d6044ff483..ce97bce504 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -1,12 +1,12 @@ using System.Text.RegularExpressions; namespace Terminal.Gui; -/* TODO : Depends on https://github.com/gui-cs/Terminal.Gui/pull/3768 + /// /// Uses Ansi escape sequences to detect whether sixel is supported /// by the terminal. /// -public class SixelSupportDetector : ISixelSupportDetector +public class SixelSupportDetector { /// /// Sends Ansi escape sequences to the console to determine whether @@ -15,102 +15,130 @@ public class SixelSupportDetector : ISixelSupportDetector /// /// Description of sixel support, may include assumptions where /// expected response codes are not returned by console. - public SixelSupportResult Detect () + public void Detect (Action resultCallback) { var result = new SixelSupportResult (); + result.SupportsTransparency = IsWindowsTerminal () || IsXtermWithTransparency (); + IsSixelSupportedByDar (result, resultCallback); + } - result.IsSupported = IsSixelSupportedByDar (); - if (result.IsSupported) - { - if (TryGetResolutionDirectly (out var res)) - { - result.Resolution = res; - } - else if(TryComputeResolution(out res)) - { - result.Resolution = res; - } + private void TryGetResolutionDirectly (SixelSupportResult result, Action resultCallback) + { + // Expect something like: + //[6;20;10t + QueueRequest (EscSeqUtils.CSI_RequestSixelResolution, + (r) => + { + // Terminal supports directly responding with resolution + var match = Regex.Match (r, @"\[\d+;(\d+);(\d+)t$"); + + if (match.Success) + { + if (int.TryParse (match.Groups [1].Value, out var ry) && + int.TryParse (match.Groups [2].Value, out var rx)) + { + result.Resolution = new Size (rx, ry); + } + } + + // Finished + resultCallback.Invoke (result); + + }, + // Request failed, so try to compute instead + ()=>TryComputeResolution (result,resultCallback)); + } - result.SupportsTransparency = IsWindowsTerminal () || IsXtermWithTransparency (); - } - return result; + private void TryComputeResolution (SixelSupportResult result, Action resultCallback) + { + string windowSize; + string sizeInChars; + + QueueRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, + (r1)=> + { + windowSize = r1; + + QueueRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, + (r2) => + { + sizeInChars = r2; + ComputeResolution (result,windowSize,sizeInChars); + resultCallback (result); + + }, abandoned: () => resultCallback (result)); + },abandoned: ()=>resultCallback(result)); } - - private bool TryGetResolutionDirectly (out Size resolution) + private void ComputeResolution (SixelSupportResult result, string windowSize, string sizeInChars) { - // Expect something like: - //[6;20;10t + // Fallback to window size in pixels and characters + // Example [4;600;1200t + var pixelMatch = Regex.Match (windowSize, @"\[\d+;(\d+);(\d+)t$"); - if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var response)) - { - // Terminal supports directly responding with resolution - var match = Regex.Match (response.Response, @"\[\d+;(\d+);(\d+)t$"); + // Example [8;30;120t + var charMatch = Regex.Match (sizeInChars, @"\[\d+;(\d+);(\d+)t$"); - if (match.Success) + if (pixelMatch.Success && charMatch.Success) + { + // Extract pixel dimensions + if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) + && int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) + && + + // Extract character dimensions + int.TryParse (charMatch.Groups [1].Value, out var charHeight) + && int.TryParse (charMatch.Groups [2].Value, out var charWidth) + && charWidth != 0 + && charHeight != 0) // Avoid divide by zero { - if (int.TryParse (match.Groups [1].Value, out var ry) && - int.TryParse (match.Groups [2].Value, out var rx)) - { - resolution = new Size (rx, ry); + // Calculate the character cell size in pixels + var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); + var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); - return true; - } + // Set the resolution based on the character cell size + result.Resolution = new Size (cellWidth, cellHeight); } } - - resolution = default; - return false; } + private void IsSixelSupportedByDar (SixelSupportResult result,Action resultCallback) + { + QueueRequest ( + EscSeqUtils.CSI_SendDeviceAttributes, + (r) => + { + result.IsSupported = ResponseIndicatesSupport (r); + + if (result.IsSupported) + { + TryGetResolutionDirectly (result, resultCallback); + } + else + { + resultCallback (result); + } + },abandoned: () => resultCallback(result)); + } - private bool TryComputeResolution (out Size resolution) + private void QueueRequest (AnsiEscapeSequenceRequest req, Action responseCallback, Action abandoned) { - // Fallback to window size in pixels and characters - if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse) - && AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse)) + var newRequest = new AnsiEscapeSequenceRequest { - // Example [4;600;1200t - var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + Request = req.Request, + Terminator = req.Terminator, + ResponseReceived = responseCallback, + Abandoned = abandoned + }; - // Example [8;30;120t - var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); - - if (pixelMatch.Success && charMatch.Success) - { - // Extract pixel dimensions - if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) - && int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) - && - - // Extract character dimensions - int.TryParse (charMatch.Groups [1].Value, out var charHeight) - && int.TryParse (charMatch.Groups [2].Value, out var charWidth) - && charWidth != 0 - && charHeight != 0) // Avoid divide by zero - { - // Calculate the character cell size in pixels - var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); - var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); - - // Set the resolution based on the character cell size - resolution = new Size (cellWidth, cellHeight); - - return true; - } - } - } - - resolution = default; - return false; + Application.Driver.QueueAnsiRequest (newRequest); } - private bool IsSixelSupportedByDar () + + private bool ResponseIndicatesSupport (string response) { - return AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) - ? darResponse.Response.Split (';').Contains ("4") - : false; + return response.Split (';').Contains ("4"); } private bool IsWindowsTerminal () @@ -130,4 +158,4 @@ private bool IsXtermWithTransparency () return false; } -}*/ \ No newline at end of file +} \ No newline at end of file diff --git a/UICatalog/Scenarios/AnsiRequestsScenario.cs b/UICatalog/Scenarios/AnsiRequestsScenario.cs new file mode 100644 index 0000000000..5175a6c52e --- /dev/null +++ b/UICatalog/Scenarios/AnsiRequestsScenario.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Terminal.Gui; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("AnsiEscapeSequenceRequest", "Ansi Escape Sequence Request")] +[ScenarioCategory ("Ansi Escape Sequence")] +public sealed class AnsiEscapeSequenceRequests : Scenario +{ + private GraphView _graphView; + + private DateTime start = DateTime.Now; + private ScatterSeries _sentSeries; + private ScatterSeries _answeredSeries; + + private List sends = new (); + + private object lockAnswers = new object (); + private Dictionary answers = new (); + private Label _lblSummary; + + public override void Main () + { + // Init + Application.Init (); + + TabView tv = new TabView + { + Width = Dim.Fill (), + Height = Dim.Fill (), + CanFocus = true + }; + + Tab single = new Tab (); + single.DisplayText = "Single"; + single.View = BuildSingleTab (); + + Tab bulk = new (); + bulk.DisplayText = "Multi"; + bulk.View = BuildBulkTab (); + + tv.AddTab (single, true); + tv.AddTab (bulk, false); + + // Setup - Create a top-level application window and configure it. + Window appWindow = new () + { + Title = GetQuitKeyAndName (), + }; + + appWindow.Add (tv); + + // Run - Start the application. + Application.Run (appWindow); + bulk.View.Dispose (); + single.View.Dispose (); + appWindow.Dispose (); + + // Shutdown - Calling Application.Shutdown is required. + Application.Shutdown (); + } + + private View BuildSingleTab () + { + View w = new View () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + CanFocus = true + }; + + w.Padding.Thickness = new (1); + + var scrRequests = new List + { + "CSI_SendDeviceAttributes", + "CSI_ReportTerminalSizeInChars", + "CSI_RequestCursorPositionReport", + "CSI_SendDeviceAttributes2" + }; + + var cbRequests = new ComboBox () { Width = 40, Height = 5, ReadOnly = true, Source = new ListWrapper (new (scrRequests)) }; + w.Add (cbRequests); + + var label = new Label { Y = Pos.Bottom (cbRequests) + 1, Text = "Request:" }; + var tfRequest = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 20 }; + w.Add (label, tfRequest); + + label = new Label { X = Pos.Right (tfRequest) + 1, Y = Pos.Top (tfRequest) - 1, Text = "Value:" }; + var tfValue = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 6 }; + w.Add (label, tfValue); + + label = new Label { X = Pos.Right (tfValue) + 1, Y = Pos.Top (tfValue) - 1, Text = "Terminator:" }; + var tfTerminator = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 4 }; + w.Add (label, tfTerminator); + + cbRequests.SelectedItemChanged += (s, e) => + { + if (cbRequests.SelectedItem == -1) + { + return; + } + + var selAnsiEscapeSequenceRequestName = scrRequests [cbRequests.SelectedItem]; + AnsiEscapeSequenceRequest selAnsiEscapeSequenceRequest = null; + + switch (selAnsiEscapeSequenceRequestName) + { + case "CSI_SendDeviceAttributes": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_SendDeviceAttributes; + + break; + case "CSI_ReportTerminalSizeInChars": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_ReportTerminalSizeInChars; + + break; + case "CSI_RequestCursorPositionReport": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_RequestCursorPositionReport; + + break; + case "CSI_SendDeviceAttributes2": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_SendDeviceAttributes2; + + break; + } + + tfRequest.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Request : ""; + tfValue.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Value ?? "" : ""; + tfTerminator.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Terminator : ""; + }; + + // Forces raise cbRequests.SelectedItemChanged to update TextFields + cbRequests.SelectedItem = 0; + + label = new Label { Y = Pos.Bottom (tfRequest) + 2, Text = "Response:" }; + var tvResponse = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 40, Height = 4, ReadOnly = true }; + w.Add (label, tvResponse); + + label = new Label { X = Pos.Right (tvResponse) + 1, Y = Pos.Top (tvResponse) - 1, Text = "Error:" }; + var tvError = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 40, Height = 4, ReadOnly = true }; + w.Add (label, tvError); + + label = new Label { X = Pos.Right (tvError) + 1, Y = Pos.Top (tvError) - 1, Text = "Value:" }; + var tvValue = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 6, Height = 4, ReadOnly = true }; + w.Add (label, tvValue); + + label = new Label { X = Pos.Right (tvValue) + 1, Y = Pos.Top (tvValue) - 1, Text = "Terminator:" }; + var tvTerminator = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 4, Height = 4, ReadOnly = true }; + w.Add (label, tvTerminator); + + var btnResponse = new Button { X = Pos.Center (), Y = Pos.Bottom (tvResponse) + 2, Text = "Send Request", IsDefault = true }; + + var lblSuccess = new Label { X = Pos.Center (), Y = Pos.Bottom (btnResponse) + 1 }; + w.Add (lblSuccess); + + btnResponse.Accepting += (s, e) => + { + var ansiEscapeSequenceRequest = new AnsiEscapeSequenceRequest + { + Request = tfRequest.Text, + Terminator = tfTerminator.Text, + Value = string.IsNullOrEmpty (tfValue.Text) ? null : tfValue.Text + }; + + Application.Driver.QueueAnsiRequest ( + new () + { + Request = ansiEscapeSequenceRequest.Request, + Terminator = ansiEscapeSequenceRequest.Terminator, + ResponseReceived = (s)=>OnSuccess(s, tvResponse, tvError, tvValue, tvTerminator,lblSuccess), + Abandoned =()=> OnFail (tvResponse, tvError, tvValue, tvTerminator, lblSuccess) + }); + }; + + w.Add (btnResponse); + + w.Add (new Label { Y = Pos.Bottom (lblSuccess) + 2, Text = "You can send other requests by editing the TextFields." }); + + return w; + } + + private void OnSuccess (string response, TextView tvResponse, TextView tvError, TextView tvValue, TextView tvTerminator,Label lblSuccess) + { + tvResponse.Text = response; + tvError.Text = string.Empty; + tvValue.Text = string.Empty; + tvTerminator.Text = string.Empty; + + lblSuccess.ColorScheme = Colors.ColorSchemes ["Base"]; + lblSuccess.Text = "Successful"; + } + + private void OnFail (TextView tvResponse, TextView tvError, TextView tvValue, TextView tvTerminator, Label lblSuccess) + { + tvResponse.Text = string.Empty; + tvError.Text = "No Response"; + tvValue.Text = string.Empty; + tvTerminator.Text = string.Empty; + + lblSuccess.ColorScheme = Colors.ColorSchemes ["Error"]; + lblSuccess.Text = "Error"; + } + + private View BuildBulkTab () + { + View w = new View () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + CanFocus = true + }; + + var lbl = new Label () + { + Text = "This scenario tests Ansi request/response processing. Use the TextView to ensure regular user interaction continues as normal during sends", + Height = 2, + Width = Dim.Fill () + }; + + Application.AddTimeout ( + TimeSpan.FromMilliseconds (1000), + () => + { + lock (lockAnswers) + { + UpdateGraph (); + + UpdateResponses (); + } + + + + return true; + }); + + var tv = new TextView () + { + Y = Pos.Bottom (lbl), + Width = Dim.Percent (50), + Height = Dim.Fill () + }; + + + var lblDar = new Label () + { + Y = Pos.Bottom (lbl), + X = Pos.Right (tv) + 1, + Text = "DAR per second", + }; + var cbDar = new NumericUpDown () + { + X = Pos.Right (lblDar), + Y = Pos.Bottom (lbl), + Value = 0, + }; + + cbDar.ValueChanging += (s, e) => + { + if (e.NewValue < 0 || e.NewValue > 20) + { + e.Cancel = true; + } + }; + w.Add (cbDar); + + int lastSendTime = Environment.TickCount; + object lockObj = new object (); + Application.AddTimeout ( + TimeSpan.FromMilliseconds (50), + () => + { + lock (lockObj) + { + if (cbDar.Value > 0) + { + int interval = 1000 / cbDar.Value; // Calculate the desired interval in milliseconds + int currentTime = Environment.TickCount; // Current system time in milliseconds + + // Check if the time elapsed since the last send is greater than the interval + if (currentTime - lastSendTime >= interval) + { + SendDar (); // Send the request + lastSendTime = currentTime; // Update the last send time + } + } + } + + return true; + }); + + + _graphView = new GraphView () + { + Y = Pos.Bottom (cbDar), + X = Pos.Right (tv), + Width = Dim.Fill (), + Height = Dim.Fill (1) + }; + + _lblSummary = new Label () + { + Y = Pos.Bottom (_graphView), + X = Pos.Right (tv), + Width = Dim.Fill () + }; + + SetupGraph (); + + w.Add (lbl); + w.Add (lblDar); + w.Add (cbDar); + w.Add (tv); + w.Add (_graphView); + w.Add (_lblSummary); + + return w; + } + private void UpdateResponses () + { + _lblSummary.Text = GetSummary (); + _lblSummary.SetNeedsDisplay (); + } + + private string GetSummary () + { + if (answers.Count == 0) + { + return "No requests sent yet"; + } + + var last = answers.Last ().Value; + + var unique = answers.Values.Distinct ().Count (); + var total = answers.Count; + + return $"Last:{last} U:{unique} T:{total}"; + } + + private void SetupGraph () + { + + _graphView.Series.Add (_sentSeries = new ScatterSeries ()); + _graphView.Series.Add (_answeredSeries = new ScatterSeries ()); + + _sentSeries.Fill = new GraphCellToRender (new Rune ('.'), new Attribute (ColorName16.BrightGreen, ColorName16.Black)); + _answeredSeries.Fill = new GraphCellToRender (new Rune ('.'), new Attribute (ColorName16.BrightRed, ColorName16.Black)); + + // Todo: + // _graphView.Annotations.Add (_sentSeries new PathAnnotation {}); + + _graphView.CellSize = new PointF (1, 1); + _graphView.MarginBottom = 2; + _graphView.AxisX.Increment = 1; + _graphView.AxisX.Text = "Seconds"; + _graphView.GraphColor = new Attribute (Color.Green, Color.Black); + } + + private void UpdateGraph () + { + _sentSeries.Points = sends + .GroupBy (ToSeconds) + .Select (g => new PointF (g.Key, g.Count ())) + .ToList (); + + _answeredSeries.Points = answers.Keys + .GroupBy (ToSeconds) + .Select (g => new PointF (g.Key, g.Count ())) + .ToList (); + // _graphView.ScrollOffset = new PointF(,0); + _graphView.SetNeedsDisplay (); + + } + + private int ToSeconds (DateTime t) + { + return (int)(DateTime.Now - t).TotalSeconds; + } + + private void SendDar () + { + Application.Driver.QueueAnsiRequest ( + new () + { + Request = EscSeqUtils.CSI_SendDeviceAttributes.Request, + Terminator = EscSeqUtils.CSI_ReportDeviceAttributes_Terminator, + ResponseReceived = HandleResponse + }); + sends.Add (DateTime.Now); + } + + private void HandleResponse (string response) + { + lock (lockAnswers) + { + answers.Add (DateTime.Now, response); + } + } +} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index e1cc7cac25..85e6e495ca 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -60,15 +60,15 @@ public class Images : Scenario private RadioGroup _rgDistanceAlgorithm; private NumericUpDown _popularityThreshold; private SixelToRender _sixelImage; - private SixelSupportResult _sixelSupportResult; + + // Start by assuming no support + private SixelSupportResult _sixelSupportResult = new SixelSupportResult (); + private CheckBox _cbSupportsSixel; public override void Main () { - // TODO: Change to the one that uses Ansi Requests later - var sixelSupportDetector = new AssumeSupportDetector (); - _sixelSupportResult = sixelSupportDetector.Detect (); - Application.Init (); + _win = new () { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; @@ -95,8 +95,8 @@ public override void Main () Text = "supports true color " }; _win.Add (cbSupportsTrueColor); - - var cbSupportsSixel = new CheckBox + + _cbSupportsSixel = new CheckBox { X = Pos.Right (lblDriverName) + 2, Y = 1, @@ -108,22 +108,22 @@ public override void Main () { X = Pos.Right (lblDriverName) + 2, - Y = Pos.Bottom (cbSupportsSixel), + Y = Pos.Bottom (_cbSupportsSixel), Text = "(Check if your terminal supports Sixel)" }; -/* CheckedState = _sixelSupportResult.IsSupported - ? CheckState.Checked - : CheckState.UnChecked;*/ - cbSupportsSixel.CheckedStateChanging += (s, e) => - { - _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; - SetupSixelSupported (e.NewValue == CheckState.Checked); - ApplyShowTabViewHack (); - }; + /* CheckedState = _sixelSupportResult.IsSupported + ? CheckState.Checked + : CheckState.UnChecked;*/ + _cbSupportsSixel.CheckedStateChanging += (s, e) => + { + _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; + SetupSixelSupported (e.NewValue == CheckState.Checked); + ApplyShowTabViewHack (); + }; - _win.Add (cbSupportsSixel); + _win.Add (_cbSupportsSixel); var cbUseTrueColor = new CheckBox { @@ -150,17 +150,32 @@ public override void Main () BuildBasicTab (tabBasic); BuildSixelTab (); - SetupSixelSupported (cbSupportsSixel.CheckedState == CheckState.Checked); + SetupSixelSupported (_cbSupportsSixel.CheckedState == CheckState.Checked); btnOpenImage.Accepting += OpenImage; _win.Add (lblSupportsSixel); _win.Add (_tabView); + + // Start trying to detect sixel support + var sixelSupportDetector = new SixelSupportDetector (); + sixelSupportDetector.Detect (UpdateSixelSupportState); + Application.Run (_win); _win.Dispose (); Application.Shutdown (); } + private void UpdateSixelSupportState (SixelSupportResult newResult) + { + _sixelSupportResult = newResult; + + _cbSupportsSixel.CheckedState = newResult.IsSupported ? CheckState.Checked : CheckState.UnChecked; + _pxX.Value = _sixelSupportResult.Resolution.Width; + _pxY.Value = _sixelSupportResult.Resolution.Height; + + } + private void SetupSixelSupported (bool isSupported) { _tabSixel.View = isSupported ? _sixelSupported : _sixelNotSupported; diff --git a/UnitTests/ConsoleDrivers/AnsiRequestSchedulerTests.cs b/UnitTests/ConsoleDrivers/AnsiRequestSchedulerTests.cs new file mode 100644 index 0000000000..3dcfaeedd7 --- /dev/null +++ b/UnitTests/ConsoleDrivers/AnsiRequestSchedulerTests.cs @@ -0,0 +1,236 @@ +using Moq; + +namespace UnitTests.ConsoleDrivers; + + +public class AnsiRequestSchedulerTests +{ + private readonly Mock _parserMock; + private readonly AnsiRequestScheduler _scheduler; + + private static DateTime _staticNow; // Static value to hold the current time + + public AnsiRequestSchedulerTests () + { + _parserMock = new Mock (MockBehavior.Strict); + _staticNow = DateTime.UtcNow; // Initialize static time + _scheduler = new AnsiRequestScheduler (_parserMock.Object, () => _staticNow); + } + + [Fact] + public void SendOrSchedule_SendsDeviceAttributeRequest_WhenNoOutstandingRequests () + { + // Arrange + var request = new AnsiEscapeSequenceRequest + { + Request = "\u001b[0c", // ESC [ c + Terminator = "c", + ResponseReceived = r => { } + }; + + // we have no outstanding for c already + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (false).Verifiable(Times.Once); + + // then we should execute our request + _parserMock.Setup (p => p.ExpectResponse ("c", It.IsAny> (), null, false)).Verifiable (Times.Once); + + // Act + bool result = _scheduler.SendOrSchedule (request); + + + // Assert + Assert.Empty (_scheduler.QueuedRequests); // We sent it i.e. we did not queue it for later + Assert.True (result); // Should send immediately + _parserMock.Verify (); + } + [Fact] + public void SendOrSchedule_QueuesRequest_WhenOutstandingRequestExists () + { + // Arrange + var request1 = new AnsiEscapeSequenceRequest + { + Request = "\u001b[0c", // ESC [ 0 c + Terminator = "c", + ResponseReceived = r => { } + }; + + // Parser already has an ongoing request for "c" + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (true).Verifiable (Times.Once); + + // Act + var result = _scheduler.SendOrSchedule (request1); + + // Assert + Assert.Single (_scheduler.QueuedRequests); // Ensure only one request is in the queue + Assert.False (result); // Should be queued + _parserMock.Verify (); + } + + + [Fact] + public void RunSchedule_ThrottleNotExceeded_AllowSend () + { + // Arrange + var request = new AnsiEscapeSequenceRequest + { + Request = "\u001b[0c", // ESC [ 0 c + Terminator = "c", + ResponseReceived = r => { } + }; + + // Set up to expect no outstanding request for "c" i.e. parser instantly gets response and resolves it + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (false).Verifiable(Times.Exactly (2)); + _parserMock.Setup (p => p.ExpectResponse ("c", It.IsAny> (), null, false)).Verifiable (Times.Exactly (2)); + + _scheduler.SendOrSchedule (request); + + // Simulate time passing beyond throttle + SetTime (101); // Exceed throttle limit + + + // Act + + // Send another request after the throttled time limit + var result = _scheduler.SendOrSchedule (request); + + // Assert + Assert.Empty (_scheduler.QueuedRequests); // Should send and clear the request + Assert.True (result); // Should have found and sent the request + _parserMock.Verify (); + } + + [Fact] + public void RunSchedule_ThrottleExceeded_QueueRequest () + { + // Arrange + var request = new AnsiEscapeSequenceRequest + { + Request = "\u001b[0c", // ESC [ 0 c + Terminator = "c", + ResponseReceived = r => { } + }; + + // Set up to expect no outstanding request for "c" i.e. parser instantly gets response and resolves it + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (false).Verifiable (Times.Exactly (2)); + _parserMock.Setup (p => p.ExpectResponse ("c", It.IsAny> (), null, false)).Verifiable (Times.Exactly (2)); + + _scheduler.SendOrSchedule (request); + + // Simulate time passing + SetTime (55); // Does not exceed throttle limit + + + // Act + + // Send another request after the throttled time limit + var result = _scheduler.SendOrSchedule (request); + + // Assert + Assert.Single (_scheduler.QueuedRequests); // Should have been queued + Assert.False(result); // Should have been queued + + // Throttle still not exceeded + Assert.False(_scheduler.RunSchedule ()); + + SetTime (90); + + // Throttle still not exceeded + Assert.False (_scheduler.RunSchedule ()); + + SetTime (105); + + // Throttle exceeded - so send the request + Assert.True (_scheduler.RunSchedule ()); + + _parserMock.Verify (); + } + + [Fact] + public void EvictStaleRequests_RemovesStaleRequest_AfterTimeout () + { + // Arrange + var request1 = new AnsiEscapeSequenceRequest + { + Request = "\u001b[0c", + Terminator = "c", + ResponseReceived = r => { } + }; + + // Send + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (false).Verifiable (Times.Once); + _parserMock.Setup (p => p.ExpectResponse ("c", It.IsAny> (), null, false)).Verifiable (Times.Exactly (2)); + + Assert.True (_scheduler.SendOrSchedule (request1)); + + // Parser already has an ongoing request for "c" + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (true).Verifiable (Times.Exactly (2)); + + // Cannot send because there is already outstanding request + Assert.False(_scheduler.SendOrSchedule (request1)); + Assert.Single (_scheduler.QueuedRequests); + + // Simulate request going stale + SetTime (5001); // Exceeds stale timeout + + // Parser should be told to give up on this one (evicted) + _parserMock.Setup (p => p.StopExpecting ("c", false)) + .Callback (() => + { + // When we tell parser to evict - it should now tell us it is no longer expecting + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (false).Verifiable (Times.Once); + }).Verifiable (); + + // When we send again the evicted one should be + var evicted = _scheduler.RunSchedule (); + + Assert.True (evicted); // Stale request should be evicted + Assert.Empty (_scheduler.QueuedRequests); + + // Assert + _parserMock.Verify (); + } + + [Fact] + public void RunSchedule_DoesNothing_WhenQueueIsEmpty () + { + // Act + var result = _scheduler.RunSchedule (); + + // Assert + Assert.False (result); // No requests to process + Assert.Empty (_scheduler.QueuedRequests); + } + + [Fact] + public void SendOrSchedule_ManagesIndependentTerminatorsCorrectly () + { + // Arrange + var request1 = new AnsiEscapeSequenceRequest { Request = "\u001b[0c", Terminator = "c", ResponseReceived = r => { } }; + var request2 = new AnsiEscapeSequenceRequest { Request = "\u001b[0x", Terminator = "x", ResponseReceived = r => { } }; + + // Already have a 'c' ongoing + _parserMock.Setup (p => p.IsExpecting ("c")).Returns (true).Verifiable (Times.Once); + + // 'x' is free + _parserMock.Setup (p => p.IsExpecting ("x")).Returns (false).Verifiable (Times.Once); + _parserMock.Setup (p => p.ExpectResponse ("x", It.IsAny> (), null, false)).Verifiable (Times.Once); + + // Act + var a = _scheduler.SendOrSchedule (request1); + var b = _scheduler.SendOrSchedule (request2); + + // Assert + Assert.False (a); + Assert.True (b); + Assert.Equal(request1, Assert.Single (_scheduler.QueuedRequests)); + _parserMock.Verify (); + } + + + private void SetTime (int milliseconds) + { + // This simulates the passing of time by setting the Now function to return a specific time. + var newNow = _staticNow.AddMilliseconds (milliseconds); + _scheduler.Now = () => newNow; + } +} \ No newline at end of file diff --git a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs new file mode 100644 index 0000000000..d3019e8d42 --- /dev/null +++ b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs @@ -0,0 +1,639 @@ +using System.Diagnostics; +using System.Text; +using Xunit.Abstractions; + +namespace UnitTests.ConsoleDrivers; +public class AnsiResponseParserTests (ITestOutputHelper output) +{ + AnsiResponseParser _parser1 = new AnsiResponseParser (); + AnsiResponseParser _parser2 = new AnsiResponseParser (); + + /// + /// Used for the T value in batches that are passed to the AnsiResponseParser<int> (parser1) + /// + private int tIndex = 0; + + [Fact] + public void TestInputProcessing () + { + string ansiStream = "\u001b[<0;10;20M" + // ANSI escape for mouse move at (10, 20) + "Hello" + // User types "Hello" + "\u001b[0c"; // Device Attributes response (e.g., terminal identification i.e. DAR) + + + string response1 = null; + string response2 = null; + + int i = 0; + + // Imagine that we are expecting a DAR + _parser1.ExpectResponse ("c",(s)=> response1 = s,null, false); + _parser2.ExpectResponse ("c", (s) => response2 = s , null, false); + + // First char is Escape which we must consume incase what follows is the DAR + AssertConsumed (ansiStream, ref i); // Esc + + for (int c = 0; c < "[<0;10;20".Length; c++) + { + AssertConsumed (ansiStream, ref i); + } + + // We see the M terminator + AssertReleased (ansiStream, ref i, "\u001b[<0;10;20M"); + + // Regular user typing + for (int c = 0; c < "Hello".Length; c++) + { + AssertIgnored (ansiStream,"Hello"[c], ref i); + } + + // Now we have entered the actual DAR we should be consuming these + for (int c = 0; c < "\u001b[0".Length; c++) + { + AssertConsumed (ansiStream, ref i); + } + + // Consume the terminator 'c' and expect this to call the above event + Assert.Null (response1); + Assert.Null (response1); + AssertConsumed (ansiStream, ref i); + Assert.NotNull (response2); + Assert.Equal ("\u001b[0c", response2); + Assert.NotNull (response2); + Assert.Equal ("\u001b[0c", response2); + } + + [Theory] + [InlineData ("\u001b[<0;10;20MHi\u001b[0c", "c", "\u001b[0c", "\u001b[<0;10;20MHi")] + [InlineData ("\u001b[<1;15;25MYou\u001b[1c", "c", "\u001b[1c", "\u001b[<1;15;25MYou")] + [InlineData ("\u001b[0cHi\u001b[0c", "c", "\u001b[0c", "Hi\u001b[0c")] + [InlineData ("\u001b[<0;0;0MHe\u001b[3c", "c", "\u001b[3c", "\u001b[<0;0;0MHe")] + [InlineData ("\u001b[<0;1;2Da\u001b[0c\u001b[1c", "c", "\u001b[0c", "\u001b[<0;1;2Da\u001b[1c")] + [InlineData ("\u001b[1;1M\u001b[3cAn", "c", "\u001b[3c", "\u001b[1;1MAn")] + [InlineData ("hi\u001b[2c\u001b[<5;5;5m", "c", "\u001b[2c", "hi\u001b[<5;5;5m")] + [InlineData ("\u001b[3c\u001b[4c\u001b[<0;0;0MIn", "c", "\u001b[3c", "\u001b[4c\u001b[<0;0;0MIn")] + [InlineData ("\u001b[<1;2;3M\u001b[0c\u001b[<1;2;3M\u001b[2c", "c", "\u001b[0c", "\u001b[<1;2;3M\u001b[<1;2;3M\u001b[2c")] + [InlineData ("\u001b[<0;1;1MHi\u001b[6c\u001b[2c\u001b[<1;0;0MT", "c", "\u001b[6c", "\u001b[<0;1;1MHi\u001b[2c\u001b[<1;0;0MT")] + [InlineData ("Te\u001b[<2;2;2M\u001b[7c", "c", "\u001b[7c", "Te\u001b[<2;2;2M")] + [InlineData ("\u001b[0c\u001b[<0;0;0M\u001b[3c\u001b[0c\u001b[1;0MT", "c", "\u001b[0c", "\u001b[<0;0;0M\u001b[3c\u001b[0c\u001b[1;0MT")] + [InlineData ("\u001b[0;0M\u001b[<0;0;0M\u001b[3cT\u001b[1c", "c", "\u001b[3c", "\u001b[0;0M\u001b[<0;0;0MT\u001b[1c")] + [InlineData ("\u001b[3c\u001b[<0;0;0M\u001b[0c\u001b[<1;1;1MIn\u001b[1c", "c", "\u001b[3c", "\u001b[<0;0;0M\u001b[0c\u001b[<1;1;1MIn\u001b[1c")] + [InlineData ("\u001b[<5;5;5M\u001b[7cEx\u001b[8c", "c", "\u001b[7c", "\u001b[<5;5;5MEx\u001b[8c")] + + // Random characters and mixed inputs + [InlineData ("\u001b[<1;1;1MJJ\u001b[9c", "c", "\u001b[9c", "\u001b[<1;1;1MJJ")] // Mixed text + [InlineData ("Be\u001b[0cAf", "c", "\u001b[0c", "BeAf")] // Escape in the middle of the string + [InlineData ("\u001b[<0;0;0M\u001b[2cNot e", "c", "\u001b[2c", "\u001b[<0;0;0MNot e")] // Unexpected sequence followed by text + [InlineData ("Just te\u001b[<0;0;0M\u001b[3c\u001b[2c\u001b[4c", "c", "\u001b[3c", "Just te\u001b[<0;0;0M\u001b[2c\u001b[4c")] // Multiple unexpected responses + [InlineData ("\u001b[1;2;3M\u001b[0c\u001b[2;2M\u001b[0;0;0MTe", "c", "\u001b[0c", "\u001b[1;2;3M\u001b[2;2M\u001b[0;0;0MTe")] // Multiple commands with responses + [InlineData ("\u001b[<3;3;3Mabc\u001b[4cde", "c", "\u001b[4c", "\u001b[<3;3;3Mabcde")] // Escape sequences mixed with regular text + + // Edge cases + [InlineData ("\u001b[0c\u001b[0c\u001b[0c", "c", "\u001b[0c", "\u001b[0c\u001b[0c")] // Multiple identical responses + [InlineData ("", "c", "", "")] // Empty input + [InlineData ("Normal", "c", "", "Normal")] // No escape sequences + [InlineData ("\u001b[<0;0;0M", "c", "", "\u001b[<0;0;0M")] // Escape sequence only + [InlineData ("\u001b[1;2;3M\u001b[0c", "c", "\u001b[0c", "\u001b[1;2;3M")] // Last response consumed + + [InlineData ("Inpu\u001b[0c\u001b[1;0;0M", "c", "\u001b[0c", "Inpu\u001b[1;0;0M")] // Single input followed by escape + [InlineData ("\u001b[2c\u001b[<5;6;7MDa", "c", "\u001b[2c", "\u001b[<5;6;7MDa")] // Multiple escape sequences followed by text + [InlineData ("\u001b[0cHi\u001b[1cGo", "c", "\u001b[0c", "Hi\u001b[1cGo")] // Normal text with multiple escape sequences + + [InlineData ("\u001b[<1;1;1MTe", "c", "", "\u001b[<1;1;1MTe")] + // Add more test cases here... + public void TestInputSequences (string ansiStream, string expectedTerminator, string expectedResponse, string expectedOutput) + { + var swGenBatches = Stopwatch.StartNew (); + int tests = 0; + + var permutations = GetBatchPermutations (ansiStream,5).ToArray (); + + swGenBatches.Stop (); + var swRunTest = Stopwatch.StartNew (); + + foreach (var batchSet in permutations) + { + tIndex = 0; + string response1 = string.Empty; + string response2 = string.Empty; + + // Register the expected response with the given terminator + _parser1.ExpectResponse (expectedTerminator, s => response1 = s, null, false); + _parser2.ExpectResponse (expectedTerminator, s => response2 = s, null, false); + + // Process the input + StringBuilder actualOutput1 = new StringBuilder (); + StringBuilder actualOutput2 = new StringBuilder (); + + foreach (var batch in batchSet) + { + var output1 = _parser1.ProcessInput (StringToBatch (batch)); + actualOutput1.Append (BatchToString (output1)); + + var output2 = _parser2.ProcessInput (batch); + actualOutput2.Append (output2); + } + + // Assert the final output minus the expected response + Assert.Equal (expectedOutput, actualOutput1.ToString()); + Assert.Equal (expectedResponse, response1); + Assert.Equal (expectedOutput, actualOutput2.ToString ()); + Assert.Equal (expectedResponse, response2); + tests++; + } + + output.WriteLine ($"Tested {tests} in {swRunTest.ElapsedMilliseconds} ms (gen batches took {swGenBatches.ElapsedMilliseconds} ms)" ); + } + + public static IEnumerable TestInputSequencesExact_Cases () + { + yield return + [ + "Esc Only", + null, + new [] + { + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty) + } + ]; + + yield return + [ + "Esc Hi with intermediate", + 'c', + new [] + { + 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), + } + ]; + } + + public class StepExpectation () + { + /// + /// The input character to feed into the parser at this step of the test + /// + public char Input { get; } + + /// + /// What should the state of the parser be after the + /// is fed in. + /// + public AnsiResponseParserState ExpectedStateAfterOperation { get; } + + /// + /// If this step should release one or more characters, put them here. + /// + public string ExpectedRelease { get; } = string.Empty; + + /// + /// If this step should result in a completing of detection of ANSI response + /// then put the expected full response sequence here. + /// + public string ExpectedAnsiResponse { get; } = string.Empty; + + public StepExpectation ( + char input, + AnsiResponseParserState expectedStateAfterOperation, + string expectedRelease = "", + string expectedAnsiResponse = "") : this () + { + Input = input; + ExpectedStateAfterOperation = expectedStateAfterOperation; + ExpectedRelease = expectedRelease; + ExpectedAnsiResponse = expectedAnsiResponse; + } + + } + + + + [MemberData(nameof(TestInputSequencesExact_Cases))] + [Theory] + public void TestInputSequencesExact (string caseName, char? terminator, IEnumerable expectedStates) + { + output.WriteLine ("Running test case:" + caseName); + + var parser = new AnsiResponseParser (); + string response = null; + + if (terminator.HasValue) + { + parser.ExpectResponse (terminator.Value.ToString (),(s)=> response = s,null, false); + } + foreach (var state in expectedStates) + { + // If we expect the response to be detected at this step + if (!string.IsNullOrWhiteSpace (state.ExpectedAnsiResponse)) + { + // Then before passing input it should be null + Assert.Null (response); + } + + var actual = parser.ProcessInput (state.Input.ToString ()); + + Assert.Equal (state.ExpectedRelease,actual); + Assert.Equal (state.ExpectedStateAfterOperation, parser.State); + + // If we expect the response to be detected at this step + if (!string.IsNullOrWhiteSpace (state.ExpectedAnsiResponse)) + { + // And after passing input it shuld be the expected value + Assert.Equal (state.ExpectedAnsiResponse, response); + } + } + } + + [Fact] + public void ReleasesEscapeAfterTimeout () + { + string input = "\u001b"; + int i = 0; + + // Esc on its own looks like it might be an esc sequence so should be consumed + AssertConsumed (input,ref i); + + // We should know when the state changed + 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); + + AssertManualReleaseIs (input); + } + + + [Fact] + public void TwoExcapesInARow () + { + // Example user presses Esc key then a DAR comes in + string input = "\u001b\u001b"; + int i = 0; + + // First Esc gets grabbed + AssertConsumed (input, ref i); + + // Upon getting the second Esc we should release the first + AssertReleased (input, ref i, "\u001b",0); + + // Assume 50ms or something has passed, lets force release as no new content + + // It should be the second escape that gets released (i.e. index 1) + AssertManualReleaseIs ("\u001b",1); + } + + [Fact] + public void TwoExcapesInARowWithTextBetween () + { + // Example user presses Esc key and types at the speed of light (normally the consumer should be handling Esc timeout) + // then a DAR comes in. + string input = "\u001bfish\u001b"; + int i = 0; + + // First Esc gets grabbed + AssertConsumed (input, ref i); // Esc + 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 (AnsiResponseParserState.Normal, _parser1.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser2.State); + + AssertReleased (input, ref i,"i",2); + AssertReleased (input, ref i, "s", 3); + AssertReleased (input, ref i, "h", 4); + + AssertConsumed (input, ref i); // Second Esc + + // Assume 50ms or something has passed, lets force release as no new content + AssertManualReleaseIs ("\u001b", 5); + } + + [Fact] + public void TestLateResponses () + { + var p = new AnsiResponseParser (); + + string responseA = null; + string responseB = null; + + p.ExpectResponse ("z",(r)=>responseA=r, null, false); + + // Some time goes by without us seeing a response + p.StopExpecting ("z", false); + + // Send our new request + p.ExpectResponse ("z", (r) => responseB = r, null, false); + + // Because we gave up on getting A, we should expect the response to be to our new request + Assert.Empty(p.ProcessInput ("\u001b[<1;2z")); + Assert.Null (responseA); + Assert.Equal ("\u001b[<1;2z", responseB); + + // Oh looks like we got one late after all - swallow it + Assert.Empty (p.ProcessInput ("\u001b[0000z")); + + // Do not expect late responses to be populated back to your variable + Assert.Null (responseA); + Assert.Equal ("\u001b[<1;2z", responseB); + + // We now have no outstanding requests (late or otherwise) so new ansi codes should just fall through + Assert.Equal ("\u001b[111z", p.ProcessInput ("\u001b[111z")); + + } + + [Fact] + public void TestPersistentResponses () + { + var p = new AnsiResponseParser (); + + int m = 0; + int M = 1; + + p.ExpectResponse ("m", _ => m++, null, true); + p.ExpectResponse ("M", _ => M++, null, true); + + // Act - Feed input strings containing ANSI sequences + p.ProcessInput ("\u001b[<0;10;10m"); // Should match and increment `m` + p.ProcessInput ("\u001b[<0;20;20m"); // Should match and increment `m` + p.ProcessInput ("\u001b[<0;30;30M"); // Should match and increment `M` + p.ProcessInput ("\u001b[<0;40;40M"); // Should match and increment `M` + p.ProcessInput ("\u001b[<0;50;50M"); // Should match and increment `M` + + // Assert - Verify that counters reflect the expected counts of each terminator + Assert.Equal (2, m); // Expected two `m` responses + Assert.Equal (4, M); // Expected three `M` responses plus the initial value of 1 + } + + [Fact] + public void TestPersistentResponses_WithMetadata () + { + var p = new AnsiResponseParser (); + + int m = 0; + + var result = new List> (); + + p.ExpectResponseT ("m", (r) => + { + result = r.ToList (); + m++; + }, + null, true); + + // Act - Feed input strings containing ANSI sequences + p.ProcessInput (StringToBatch("\u001b[<0;10;10m")); // Should match and increment `m` + + // Prepare expected result: + var expected = new List> + { + Tuple.Create('\u001b', 0), // Escape character + Tuple.Create('[', 1), + Tuple.Create('<', 2), + Tuple.Create('0', 3), + Tuple.Create(';', 4), + Tuple.Create('1', 5), + Tuple.Create('0', 6), + Tuple.Create(';', 7), + Tuple.Create('1', 8), + Tuple.Create('0', 9), + Tuple.Create('m', 10) + }; + + Assert.Equal (expected.Count, result.Count); // Ensure the count is as expected + Assert.True (expected.SequenceEqual (result), "The result does not match the expected output."); // Check the actual content + } + + [Fact] + public void ShouldSwallowUnknownResponses_WhenDelegateSaysSo () + { + int i = 0; + + // Swallow all unknown escape codes + _parser1.UnexpectedResponseHandler = _ => true; + _parser2.UnknownResponseHandler = _ => true; + + + AssertReleased ( + "Just te\u001b[<0;0;0M\u001b[3c\u001b[2c\u001b[4cst", + "Just test", + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 28, + 29); + } + + [Fact] + public void UnknownResponses_ParameterShouldMatch () + { + int i = 0; + + // Track unknown responses passed to the UnexpectedResponseHandler + var unknownResponses = new List (); + + // Set up the UnexpectedResponseHandler to log each unknown response + _parser1.UnexpectedResponseHandler = r1 => + { + unknownResponses.Add (BatchToString (r1)); + return true; // Return true to swallow unknown responses + }; + + _parser2.UnknownResponseHandler = r2 => + { + // parsers should be agreeing on what these responses are! + Assert.Equal(unknownResponses.Last(),r2); + return true; // Return true to swallow unknown responses + }; + + // Input with known and unknown responses + AssertReleased ( + "Just te\u001b[<0;0;0M\u001b[3c\u001b[2c\u001b[4cst", + "Just test"); + + // Expected unknown responses (ANSI sequences that are unknown) + var expectedUnknownResponses = new List + { + "\u001b[<0;0;0M", + "\u001b[3c", + "\u001b[2c", + "\u001b[4c" + }; + + // Assert that the UnexpectedResponseHandler was called with the correct unknown responses + Assert.Equal (expectedUnknownResponses.Count, unknownResponses.Count); + Assert.Equal (expectedUnknownResponses, unknownResponses); + } + + private Tuple [] StringToBatch (string batch) + { + return batch.Select ((k) => Tuple.Create (k, tIndex++)).ToArray (); + } + + public static IEnumerable GetBatchPermutations (string input, int maxDepth = 3) + { + // Call the recursive method to generate batches with an initial depth of 0 + return GenerateBatches (input, 0, maxDepth, 0); + } + + private static IEnumerable GenerateBatches (string input, int start, int maxDepth, int currentDepth) + { + // If we have reached the maximum recursion depth, return no results + if (currentDepth >= maxDepth) + { + yield break; // No more batches can be generated at this depth + } + + // If we have reached the end of the string, return an empty list + if (start >= input.Length) + { + yield return new string [0]; + yield break; + } + + // Iterate over the input string to create batches + for (int i = start + 1; i <= input.Length; i++) + { + // Take a batch from 'start' to 'i' + string batch = input.Substring (start, i - start); + + // Recursively get batches from the remaining substring, increasing the depth + foreach (var remainingBatches in GenerateBatches (input, i, maxDepth, currentDepth + 1)) + { + // Combine the current batch with the remaining batches + var result = new string [1 + remainingBatches.Length]; + result [0] = batch; + Array.Copy (remainingBatches, 0, result, 1, remainingBatches.Length); + yield return result; + } + } + } + + private void AssertIgnored (string ansiStream,char expected, ref int i) + { + var c2 = ansiStream [i]; + var c1 = NextChar (ansiStream, ref i); + + // Parser does not grab this key (i.e. driver can continue with regular operations) + Assert.Equal ( c1,_parser1.ProcessInput (c1)); + Assert.Equal (expected,c1.Single().Item1); + + Assert.Equal (c2, _parser2.ProcessInput (c2.ToString()).Single()); + Assert.Equal (expected, c2 ); + } + private void AssertConsumed (string ansiStream, ref int i) + { + // Parser grabs this key + var c2 = ansiStream [i]; + var c1 = NextChar (ansiStream, ref i); + + Assert.Empty (_parser1.ProcessInput(c1)); + Assert.Empty (_parser2.ProcessInput (c2.ToString())); + } + + /// + /// Overload that fully exhausts and asserts + /// that the final released content across whole processing is + /// + /// + /// + /// + private void AssertReleased (string ansiStream, string expectedRelease, params int [] expectedTValues) + { + var sb = new StringBuilder (); + var tValues = new List (); + + int i = 0; + + while (i < ansiStream.Length) + { + var c2 = ansiStream [i]; + var c1 = NextChar (ansiStream, ref i); + + var released1 = _parser1.ProcessInput (c1).ToArray (); + tValues.AddRange(released1.Select (kv => kv.Item2)); + + + var released2 = _parser2.ProcessInput (c2.ToString ()); + + // Both parsers should have same chars so release chars consistently with each other + Assert.Equal (BatchToString(released1),released2); + + sb.Append (released2); + } + + Assert.Equal (expectedRelease, sb.ToString()); + + if (expectedTValues.Length > 0) + { + Assert.True (expectedTValues.SequenceEqual (tValues)); + } + } + + /// + /// Asserts that index of when consumed will release + /// . Results in implicit increment of . + /// Note that this does NOT iteratively consume all the stream, only 1 char at + /// + /// + /// + /// + /// + private void AssertReleased (string ansiStream, ref int i, string expectedRelease, params int[] expectedTValues) + { + var c2 = ansiStream [i]; + var c1 = NextChar (ansiStream, ref i); + + // Parser realizes it has grabbed content that does not belong to an outstanding request + // Parser returns false to indicate to continue + var released1 = _parser1.ProcessInput (c1).ToArray (); + Assert.Equal (expectedRelease, BatchToString (released1)); + + if (expectedTValues.Length > 0) + { + Assert.True (expectedTValues.SequenceEqual (released1.Select (kv=>kv.Item2))); + } + + Assert.Equal (expectedRelease, _parser2.ProcessInput (c2.ToString ())); + } + + private string BatchToString (IEnumerable> processInput) + { + return new string(processInput.Select (a=>a.Item1).ToArray ()); + } + + private Tuple[] NextChar (string ansiStream, ref int i) + { + return StringToBatch(ansiStream [i++].ToString()); + } + private void AssertManualReleaseIs (string expectedRelease, params int [] expectedTValues) + { + + // Consumer is responsible for determining this based on e.g. after 50ms + var released1 = _parser1.Release ().ToArray (); + Assert.Equal (expectedRelease, BatchToString (released1)); + + if (expectedTValues.Length > 0) + { + Assert.True (expectedTValues.SequenceEqual (released1.Select (kv => kv.Item2))); + } + + Assert.Equal (expectedRelease, _parser2.Release ()); + + Assert.Equal (AnsiResponseParserState.Normal, _parser1.State); + Assert.Equal (AnsiResponseParserState.Normal, _parser2.State); + } +}