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);
+ }
+}