Skip to content

Commit

Permalink
Merge pull request gui-cs#3791 from tznind/ansi-parser
Browse files Browse the repository at this point in the history
Fixes gui-cs#3767 - Adds Ansi parser and scheduler.
  • Loading branch information
tig authored Dec 7, 2024
2 parents a9769e9 + 903a886 commit 62641c8
Show file tree
Hide file tree
Showing 58 changed files with 2,838 additions and 593 deletions.
7 changes: 7 additions & 0 deletions Terminal.Gui/Application/MainLoop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ internal void RunIteration ()
}
}

RunAnsiScheduler ();

MainLoopDriver?.Iteration ();

bool runIdle;
Expand All @@ -283,6 +285,11 @@ internal void RunIteration ()
}
}

private void RunAnsiScheduler ()
{
Application.Driver?.GetRequestScheduler ().RunSchedule ();
}

/// <summary>Stops the main loop driver and calls <see cref="IMainLoopDriver.Wakeup"/>. Used only for unit tests.</summary>
internal void Stop ()
{
Expand Down
49 changes: 49 additions & 0 deletions Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#nullable enable
namespace Terminal.Gui;

/// <summary>
/// Describes an Ansi escape sequence. This is a 'blueprint'. If you
/// want to send the sequence you should instead use <see cref="AnsiEscapeSequenceRequest"/>
/// </summary>
public class AnsiEscapeSequence
{
/// <summary>
/// Request to send e.g. see
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Request</cref>
/// </see>
/// </summary>
public required string Request { get; init; }

/// <summary>
/// <para>
/// The terminator that uniquely identifies the type of response as responded
/// by the console. e.g. for
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Request</cref>
/// </see>
/// the terminator is
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Terminator</cref>
/// </see>
/// .
/// </para>
/// <para>
/// After sending a request, the first response with matching terminator will be matched
/// to the oldest outstanding request.
/// </para>
/// </summary>
public required string Terminator { get; init; }



/// <summary>
/// The value expected in the response e.g.
/// <see>
/// <cref>EscSeqUtils.CSI_ReportTerminalSizeInChars.Value</cref>
/// </see>
/// which will have a 't' as terminator but also other different request may return the same terminator with a
/// different value.
/// </summary>
public string? Value { get; init; }
}
30 changes: 30 additions & 0 deletions Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#nullable enable
namespace Terminal.Gui;

/// <summary>
/// Describes an ongoing ANSI request sent to the console.
/// Use <see cref="ResponseReceived"/> to handle the response
/// when console answers the request.
/// </summary>
public class AnsiEscapeSequenceRequest : AnsiEscapeSequence
{
/// <summary>
/// Invoked when the console responds with an ANSI response code that matches the
/// <see cref="AnsiEscapeSequence.Terminator"/>
/// </summary>
public required Action<string> ResponseReceived { get; init; }

/// <summary>
/// Invoked if the console fails to responds to the ANSI response code
/// </summary>
public Action? Abandoned { get; init; }


/// <summary>
/// Sends the <see cref="Request"/> to the raw output stream of the current <see cref="ConsoleDriver"/>.
/// Only call this method from the main UI thread. You should use <see cref="AnsiRequestScheduler"/> if
/// sending many requests.
/// </summary>
public void Send () { Application.Driver?.WriteRaw (Request); }

}
216 changes: 216 additions & 0 deletions Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
#nullable enable
using System.Collections.Concurrent;

namespace Terminal.Gui;

/// <summary>
/// Manages <see cref="AnsiEscapeSequenceRequest"/> made to an <see cref="IAnsiResponseParser"/>.
/// 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).
/// </summary>
public class AnsiRequestScheduler
{
private readonly IAnsiResponseParser _parser;

/// <summary>
/// Function for returning the current time. Use in unit tests to
/// ensure repeatable tests.
/// </summary>
internal Func<DateTime> Now { get; set; }

private readonly HashSet<Tuple<AnsiEscapeSequenceRequest, DateTime>> _queuedRequests = new ();

internal IReadOnlyCollection<AnsiEscapeSequenceRequest> QueuedRequests => _queuedRequests.Select (r => r.Item1).ToList ();

/// <summary>
/// <para>
/// Dictionary where key is ansi request terminator and value is when we last sent a request for
/// this terminator. Combined with <see cref="_throttle"/> 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.
/// </para>
/// <para>
/// When user exceeds the throttle, new requests accumulate in <see cref="_queuedRequests"/> (i.e. remain
/// queued).
/// </para>
/// </summary>
private readonly ConcurrentDictionary<string, DateTime> _lastSend = new ();

/// <summary>
/// Number of milliseconds after sending a request that we allow
/// another request to go out.
/// </summary>
private readonly TimeSpan _throttle = TimeSpan.FromMilliseconds (100);

private readonly TimeSpan _runScheduleThrottle = TimeSpan.FromMilliseconds (100);

/// <summary>
/// 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.
/// </summary>
private readonly TimeSpan _staleTimeout = TimeSpan.FromSeconds (1);

private readonly DateTime _lastRun;

/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="parser"></param>
/// <param name="now"></param>
public AnsiRequestScheduler (IAnsiResponseParser parser, Func<DateTime>? now = null)
{
_parser = parser;
Now = now ?? (() => DateTime.Now);
_lastRun = Now ();
}

/// <summary>
/// Sends the <paramref name="request"/> immediately or queues it if there is already
/// an outstanding request for the given <see cref="AnsiEscapeSequenceRequest.Terminator"/>.
/// </summary>
/// <param name="request"></param>
/// <returns><see langword="true"/> if request was sent immediately. <see langword="false"/> if it was queued.</returns>
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 (string stale in _lastSend.Where (v => IsStale (v.Value)).Select (k => k.Key))
{
EvictStaleRequests (stale);
}
}

private bool IsStale (DateTime dt) { return Now () - dt > _staleTimeout; }

/// <summary>
/// Looks to see if the last time we sent <paramref name="withTerminator"/>
/// 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 <see langword="true"/>).
/// </summary>
/// <param name="withTerminator"></param>
/// <returns></returns>
private bool EvictStaleRequests (string withTerminator)
{
if (_lastSend.TryGetValue (withTerminator, out DateTime dt))
{
if (IsStale (dt))
{
_parser.StopExpecting (withTerminator, false);

return true;
}
}

return false;
}

/// <summary>
/// Identifies and runs any <see cref="_queuedRequests"/> that can be sent based on the
/// current outstanding requests of the parser.
/// </summary>
/// <param name="force">
/// Repeated requests to run the schedule over short period of time will be ignored.
/// Pass <see langword="true"/> to override this behaviour and force evaluation of outstanding requests.
/// </param>
/// <returns>
/// <see langword="true"/> if a request was found and run. <see langword="false"/>
/// if no outstanding requests or all have existing outstanding requests underway in parser.
/// </returns>
public bool RunSchedule (bool force = false)
{
if (!force && Now () - _lastRun < _runScheduleThrottle)
{
return false;
}

// Get oldest request
Tuple<AnsiEscapeSequenceRequest, DateTime>? 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#nullable enable
namespace Terminal.Gui;

internal record AnsiResponseExpectation (string Terminator, Action<IHeld> Response, Action? Abandoned)
{
public bool Matches (string cur) { return cur.EndsWith (Terminator); }
}
Loading

0 comments on commit 62641c8

Please sign in to comment.