Browse Source

Improved ansi control sequence support in console

pull/55/head
Ionite 1 year ago
parent
commit
deda26b295
No known key found for this signature in database
  1. 1
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  2. 307
      StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
  3. 8
      StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs
  4. 30
      StabilityMatrix.Core/Processes/AnsiCommand.cs
  5. 13
      StabilityMatrix.Core/Processes/AnsiParser.cs
  6. 36
      StabilityMatrix.Core/Processes/AsyncStreamReader.cs
  7. 66
      StabilityMatrix.Core/Processes/ProcessOutput.cs

1
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -26,6 +26,7 @@
<PackageReference Include="Markdown.Avalonia" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.8" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NLog" Version="5.2.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.2" />
<PackageReference Include="Polly" Version="7.2.4" />

307
StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs

@ -1,11 +1,13 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.ComponentModel;
using Nito.AsyncEx;
using NLog;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Processes;
@ -27,20 +29,43 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable
[ObservableProperty] private TextDocument document = new();
// Tracks the global write cursor offset
/// <summary>
/// Current offset for write operations.
/// </summary>
private int writeCursor;
// Lock for accessing the write cursor
private readonly object writeCursorLock = new();
/// <summary>
/// Lock for accessing <see cref="writeCursor"/>
/// </summary>
private readonly AsyncLock writeCursorLock = new();
/// <summary>
/// Timeout for acquiring locks on <see cref="writeCursor"/>
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public TimeSpan WriteCursorLockTimeout { get; init; } = TimeSpan.FromMilliseconds(100);
// Special instruction events
/// <summary>
/// Gets a cancellation token using the cursor lock timeout
/// </summary>
private CancellationToken WriteCursorLockTimeoutToken =>
new CancellationTokenSource(WriteCursorLockTimeout).Token;
/// <summary>
/// Event invoked when an ApcMessage of type Input is received.
/// </summary>
public event EventHandler<ApcMessage>? ApcInput;
/// <summary>
/// Starts update task for processing Post messages.
/// </summary>
/// <exception cref="InvalidOperationException">If update task is already running</exception>
public void StartUpdates()
{
if (updateTask is not null)
{
throw new InvalidOperationException("Update task is already running");
}
updateCts = new CancellationTokenSource();
updateTask = Dispatcher.UIThread.InvokeAsync(ConsoleUpdateLoop, DispatcherPriority.Render);
}
@ -51,7 +76,6 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable
public async Task StopUpdatesAsync()
{
Logger.Trace($"Stopping console updates, current buffer items: {buffer.Count}");
await Task.Delay(100);
// First complete the buffer
buffer.Complete();
// Wait for buffer to complete, max 3 seconds
@ -62,12 +86,15 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable
}
catch (TaskCanceledException e)
{
// We can still continue since this just means we lose
// some remaining output
Logger.Warn("Buffer completion timed out: " + e.Message);
}
// Cancel update task
updateCts?.Cancel();
updateCts = null;
// Wait for update task
if (updateTask is not null)
{
@ -79,53 +106,92 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable
/// <summary>
/// Clears the console and sets a new buffer.
/// This also resets the write cursor to 0.
/// </summary>
public void Clear()
public async Task Clear()
{
// Clear document
Document.Text = string.Empty;
// Reset write cursor
await ResetWriteCursor();
// Clear buffer and create new one
buffer.Complete();
buffer = new BufferBlock<ProcessOutput>();
}
private async Task ConsoleUpdateLoop()
/// <summary>
/// Resets the write cursor to be equal to the document length.
/// </summary>
public async Task ResetWriteCursor()
{
// This must be run in the UI thread
Dispatcher.UIThread.CheckAccess();
// Update cancellation token must be set
if (updateCts is null)
using (await writeCursorLock.LockAsync(WriteCursorLockTimeoutToken))
{
throw new InvalidOperationException("Update cancellation token must be set");
Debug.WriteLine($"Reset cursor to end: ({writeCursor} -> {Document.TextLength})");
writeCursor = Document.TextLength;
}
DebugPrintDocument();
}
private async Task ConsoleUpdateLoop()
{
// This must be run in the UI thread
Dispatcher.UIThread.VerifyAccess();
// Get cancellation token
var ct = updateCts.Token;
var ct = updateCts?.Token
?? throw new InvalidOperationException("Update cancellation token must be set");
try
{
while (!ct.IsCancellationRequested)
{
var output = await buffer.ReceiveAsync(ct);
Logger.Trace($"Processing output: (Text = {output.Text.ToRepr()}, " +
$"ClearLines = {output.CarriageReturn}, CursorUp = {output.CursorUp})");
ConsoleUpdateOne(output);
}
ProcessOutput output;
try
{
output = await buffer.ReceiveAsync(ct);
}
catch (InvalidOperationException e)
{
Logger.Info($"Console update loop stopped: {e.Message}");
// Thrown when buffer is completed, convert to OperationCanceledException
throw new OperationCanceledException("Update buffer completed", e);
}
Debug.WriteLine($"Processing: (Text = {output.Text.ToRepr()}, " +
$"Raw = {output.RawText?.ToRepr()}, " +
$"CarriageReturn = {output.CarriageReturn}, " +
$"CursorUp = {output.CursorUp})");
// Link the cancellation token to the write cursor lock timeout
var linkedCt = CancellationTokenSource
.CreateLinkedTokenSource(ct, WriteCursorLockTimeoutToken).Token;
using (await writeCursorLock.LockAsync(linkedCt))
{
ConsoleUpdateOne(output);
}
}
}
catch (OperationCanceledException e)
{
Logger.Info($"Console update loop stopped: {e.Message}");
Logger.Debug($"Console update loop canceled: {e.Message}");
}
catch (Exception e)
{
// Log other errors and continue here to not crash the UI thread
Logger.Error(e, $"Unexpected error in console update loop: {e.GetType().Name} {e.Message}");
}
}
/// <summary>
/// Handle one instance of ProcessOutput.
/// Calls to this function must be synchronized with <see cref="writeCursorLock"/>
/// </summary>
/// <remarks>Not checked, but must be run in the UI thread.</remarks>
private void ConsoleUpdateOne(ProcessOutput output)
{
Debug.Assert(Dispatcher.UIThread.CheckAccess());
// Check for Apc messages
if (output.ApcMessage is not null)
{
@ -140,79 +206,198 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable
}
// If we have a carriage return,
// start current write at the beginning of the last line
// start current write at the beginning of the current line
if (output.CarriageReturn > 0)
{
var lastLineIndex = Document.LineCount - 1;
var line = Document.Lines[lastLineIndex];
var currentLine = Document.GetLineByOffset(writeCursor);
// Get the start of line offset
var lineStartOffset = line.Offset;
// Get the start of current line as new write cursor
var lineStartOffset = currentLine.Offset;
// Use this as new write cursor
if (writeCursor != lineStartOffset)
// See if we need to move the cursor
if (lineStartOffset == writeCursor)
{
lock (writeCursorLock)
Debug.WriteLine($"Cursor already at start for carriage return " +
$"(offset = {lineStartOffset}, line = {currentLine.LineNumber})");
}
else
{
Debug.WriteLine($"Moving cursor to start for carriage return " +
$"({writeCursor} -> {lineStartOffset})");
writeCursor = lineStartOffset;
}
}
}
// Insert text
// Write new text
if (!string.IsNullOrEmpty(output.Text))
{
var currentCursor = writeCursor;
using var _ = Document.RunUpdate();
// Check if the cursor is lower than the document length
// If so, we need to replace the text first
var replaceLength = Math.Min(Document.TextLength - currentCursor, output.Text.Length);
if (replaceLength > 0)
DirectWriteLinesToConsole(output.Text);
}
// Handle cursor movements
if (output.CursorUp > 0)
{
Document.Replace(currentCursor, replaceLength, output.Text[..replaceLength]);
Debug.WriteLine($"Replacing: offset = {currentCursor}, length = {replaceLength}, " +
$"text = {output.Text[..replaceLength].ToRepr()}");
// Get the line and column of the current cursor
var currentLocation = Document.GetLocation(writeCursor);
lock (writeCursorLock)
if (currentLocation.Line == 1)
{
writeCursor += replaceLength;
// We are already on the first line, ignore
Debug.WriteLine($"Cursor up: Already on first line");
}
else
{
// We want to move up one line
var targetLocation = new TextLocation(currentLocation.Line - 1, currentLocation.Column);
var targetOffset = Document.GetOffset(targetLocation);
// Update cursor to target offset
Debug.WriteLine($"Cursor up: Moving (line {currentLocation.Line}, {writeCursor})" +
$" -> (line {targetLocation.Line}, {targetOffset})");
writeCursor = targetOffset;
}
// If we replaced less than content.Length, we need to insert the rest
var remainingLength = output.Text.Length - replaceLength;
if (remainingLength > 0)
}
// Handle erase commands, different to cursor move as they don't move the cursor
// We'll insert blank spaces instead
if (output.AnsiCommand.HasFlag(AnsiCommand.EraseLine))
{
Document.Insert(writeCursor, output.Text[replaceLength..]);
Debug.WriteLine($"Inserting: offset = {writeCursor}, " +
$"text = {output.Text[replaceLength..].ToRepr()}");
// Get the current line, we'll insert spaces from start to end
var currentLine = Document.GetLineByOffset(writeCursor);
// Make some spaces to insert
var spaces = new string(' ', currentLine.Length);
// Insert the text
Debug.WriteLine($"Erasing line {currentLine.LineNumber}: (length = {currentLine.Length})");
Document.Replace(currentLine.Offset, currentLine.Length, spaces);
}
lock (writeCursorLock)
DebugPrintDocument();
}
/// <summary>
/// Write text potentially containing line breaks to the console.
/// <remarks>This call will hold a upgradeable read lock</remarks>
/// </summary>
private void DirectWriteLinesToConsole(string text)
{
// When our cursor is not at end, newlines should be interpreted as commands to
// move cursor forward to the next linebreak instead of inserting a newline.
// If text contains no newlines, we can just call DirectWriteToConsole
// Also if cursor is equal to document length
if (!text.Contains(Environment.NewLine) || writeCursor == Document.TextLength)
{
DirectWriteToConsole(text);
return;
}
// Otherwise we need to handle how linebreaks are treated
// Split text into lines
var lines = text.Split(Environment.NewLine).ToList();
foreach (var (i, lineText) in lines.SkipLast(1).Enumerate())
{
// Insert text
DirectWriteToConsole(lineText);
// Set cursor to start of next line, if we're not already there
var currentLine = Document.GetLineByOffset(writeCursor);
// If next line is available, move cursor to start of next line
if (currentLine.LineNumber < Document.LineCount)
{
var nextLine = Document.GetLineByNumber(currentLine.LineNumber + 1);
Debug.WriteLine($"Moving cursor to start of next line " +
$"({writeCursor} -> {nextLine.Offset})");
writeCursor = nextLine.Offset;
}
else
{
writeCursor += remainingLength;
// Otherwise move to end of current line, and direct insert a newline
var lineEndOffset = currentLine.EndOffset;
Debug.WriteLine($"Moving cursor to end of current line " +
$"({writeCursor} -> {lineEndOffset})");
writeCursor = lineEndOffset;
DirectWriteToConsole(Environment.NewLine);
}
}
}
// Handle cursor movements
if (output.CursorUp > 0)
/// <summary>
/// Write text to the console, does not handle newlines.
/// This should probably only be used by <see cref="DirectWriteLinesToConsole"/>
/// <remarks>This call will hold a upgradeable read lock</remarks>
/// </summary>
private void DirectWriteToConsole(string text)
{
using (Document.RunUpdate())
{
var currentCursor = writeCursor;
// First get the line the current cursor is on
var currentCursorLineNum = Document.GetLineByOffset(currentCursor).LineNumber;
// Need to replace text first if cursor lower than document length
var replaceLength = Math.Min(Document.TextLength - writeCursor, text.Length);
if (replaceLength > 0)
{
var newText = text[..replaceLength];
Debug.WriteLine(
$"Replacing: (cursor = {writeCursor}, length = {replaceLength}, " +
$"text = {Document.GetText(writeCursor, replaceLength).ToRepr()} " +
$"-> {newText.ToRepr()})");
// We want to move to the line above the current cursor line
var previousLineNum = Math.Min(0, currentCursorLineNum - output.CursorUp);
var previousLine = Document.GetLineByNumber(previousLineNum);
Document.Replace(writeCursor, replaceLength, newText);
writeCursor += replaceLength;
}
// Set the cursor to the *end* of the previous line
Logger.Trace($"Moving cursor up ({currentCursor} -> {previousLine.EndOffset})");
lock (writeCursorLock)
// If we replaced less than content.Length, we need to insert the rest
var remainingLength = text.Length - replaceLength;
if (remainingLength > 0)
{
writeCursor = previousLine.EndOffset;
var textToInsert = text[replaceLength..];
Debug.WriteLine($"Inserting: (cursor = {writeCursor}, " +
$"text = {textToInsert.ToRepr()})");
Document.Insert(writeCursor, textToInsert);
writeCursor += textToInsert.Length;
}
}
}
/// <summary>
/// Debug function to print the current document to the console.
/// Includes formatted cursor position.
/// </summary>
[Conditional("DEBUG")]
private void DebugPrintDocument()
{
var text = Document.Text;
// Add a number for each line
// Add an arrow line for where the cursor is, for example (cursor on offset 3):
//
// 1 | This is the first line
// ~~~~~~~^ (3)
// 2 | This is the second line
//
var lines = text.Split(Environment.NewLine).ToList();
var numberPadding = lines.Count.ToString().Length;
for (var i = 0; i < lines.Count; i++)
{
lines[i] = $"{(i + 1).ToString().PadLeft(numberPadding)} | {lines[i]}";
}
var cursorLine = Document.GetLineByOffset(writeCursor);
var cursorLineOffset = writeCursor - cursorLine.Offset;
// Need to account for padding + line number + space + cursor line offset
var linePadding = numberPadding + 3 + cursorLineOffset;
var cursorLineArrow = new string('~', linePadding) + $"^ ({writeCursor})";
lines.Insert(cursorLine.LineNumber, cursorLineArrow);
var textWithCursor = string.Join(Environment.NewLine, lines);
Debug.WriteLine("[Current Document]");
Debug.WriteLine(textWithCursor);
}
/// <summary>
/// Posts an update to the console
/// <remarks>Safe to call on non-UI threads</remarks>

8
StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs

@ -1,19 +1,16 @@
using System;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using AsyncAwaitBestPractices;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
@ -185,7 +182,7 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable
// Clear console and start update processing
await Console.StopUpdatesAsync();
Console.Clear();
await Console.Clear();
Console.StartUpdates();
// TODO: Update shared folder links (in case library paths changed)
@ -365,6 +362,9 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable
await Console.StopUpdatesAsync();
// Need to reset cursor in case its in some weird position
// from progress bars
await Console.ResetWriteCursor();
Console.PostLine($"{Environment.NewLine}Process finished with exit code {exitCode}");
}).SafeFireAndForget();

30
StabilityMatrix.Core/Processes/AnsiCommand.cs

@ -0,0 +1,30 @@
namespace StabilityMatrix.Core.Processes;
[Flags]
public enum AnsiCommand
{
/// <summary>
/// Default value
/// </summary>
None = 0,
// Erase commands
/// <summary>
/// Erase from cursor to end of line
/// ESC[K or ESC[0K
/// </summary>
EraseToEndOfLine = 1 << 0,
/// <summary>
/// Erase from start of line to cursor
/// ESC[1K
/// </summary>
EraseFromStartOfLine = 1 << 1,
/// <summary>
/// Erase entire line
/// ESC[2K
/// </summary>
EraseLine = 1 << 2,
}

13
StabilityMatrix.Core/Processes/AnsiParser.cs

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace StabilityMatrix.Core.Processes;
public static partial class AnsiParser
{
/// <summary>
/// From https://github.com/chalk/ansi-regex
/// </summary>
/// <returns></returns>
[GeneratedRegex(@"[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))")]
public static partial Regex AnsiEscapeSequenceRegex();
}

36
StabilityMatrix.Core/Processes/AsyncStreamReader.cs

@ -255,8 +255,18 @@ internal sealed class AsyncStreamReader : IDisposable
}
else
{
// otherwise we ignore \r and treat it as normal char
// Set flag to indicate we've seen a \r
lastCarriageReturnIndex = currentIndex;
// Send buffer up to this point, not including \r
var line = _sb.ToString(lineStart, currentIndex - lineStart);
lineStart = currentIndex;
currentIndex++;
lock (_messageQueue)
{
_messageQueue.Enqueue(line);
}
}
break;
}
@ -301,16 +311,36 @@ internal sealed class AsyncStreamReader : IDisposable
// Kind of behaves like newlines
case '\u001b':
{
Debug.WriteLine("Sending early buffer due to Ansi escape");
// Unlike '\n', this char is not included in the line
var line = _sb.ToString(lineStart, currentIndex - lineStart);
lineStart = currentIndex;
lock (_messageQueue)
{
_messageQueue.Enqueue(line);
}
// Look ahead and match the escape sequence
var remaining = _sb.ToString(currentIndex, _sb.Length - currentIndex);
var result = AnsiParser.AnsiEscapeSequenceRegex().Match(remaining);
// If we found a match, send the escape sequence match, and move forward
if (result.Success)
{
var escapeSequence = result.Value;
Debug.WriteLine($"AsyncStreamReader - Sent Ansi escape sequence: {escapeSequence.ToRepr()}");
lock (_messageQueue)
{
_messageQueue.Enqueue(escapeSequence);
}
// Advance currentIndex and lineStart to end of escape sequence
currentIndex += escapeSequence.Length;
lineStart = currentIndex;
}
else
{
Debug.WriteLine($"AsyncStreamReader - No match for Ansi escape sequence: {remaining.ToRepr()}");
}
break;
}
}

66
StabilityMatrix.Core/Processes/ProcessOutput.cs

@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Diagnostics;
using System.Text.RegularExpressions;
using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Processes;
@ -39,6 +40,11 @@ public readonly record struct ProcessOutput
/// </summary>
public int CursorUp { get; init; }
/// <summary>
/// Flag-type Ansi commands
/// </summary>
public AnsiCommand AnsiCommand { get; init; }
/// <summary>
/// Apc message sent from the subprocess
/// </summary>
@ -75,28 +81,67 @@ public readonly record struct ProcessOutput
// Remove \r from the beginning of the line, and add them to count
var clearLines = 0;
// Skip if starts with \r\n on windows
if (!text.StartsWith(Environment.NewLine))
// Strip leading carriage return until newline
while (!text.StartsWith(Environment.NewLine) && text.StartsWith('\r'))
{
clearLines += text.CountStart('\r');
text = text.TrimStart('\r');
text = text[1..];
clearLines++;
}
// Also detect Ansi escape for cursor up, treat as clear lines also
// Detect ansi escape sequences
var ansiCommands = AnsiCommand.None;
var cursorUp = 0;
if (text.Contains('\u001b'))
{
var match = Regex.Match(text, @"\x1B\[(\d+)?A");
if (match.Success)
// Cursor up sequence - ESC[#A
// Where # is count of lines to move up, if not specified, default to 1
if (Regex.Match(text, @"\x1B\[(\d+)?A") is {Success: true} match)
{
// Default to 1 if no count
cursorUp = int.TryParse(match.Groups[1].Value, out var n) ? n : 1;
// Set text to be everything up to the match
text = text[..match.Index];
// Remove the sequence from the text
text = text[..match.Index] + text[(match.Index + match.Length)..];
}
// Erase line sequence - ESC[#K
// (For erasing we don't move the cursor)
// Omitted - defaults to 0
// 0 - clear from cursor to end of line
// 1 - clear from start of line to cursor
// 2 - clear entire line
if (Regex.Match(text, @"\x1B\[(0|1|2)?K") is {Success: true} match2)
{
// Default to 0 if no count
var eraseLineMode = int.TryParse(match2.Groups[1].Value, out var n) ? n : 0;
ansiCommands |= eraseLineMode switch
{
0 => AnsiCommand.EraseToEndOfLine,
1 => AnsiCommand.EraseFromStartOfLine,
2 => AnsiCommand.EraseLine,
_ => AnsiCommand.None
};
// Remove the sequence from the text
text = text[..match2.Index] + text[(match2.Index + match2.Length)..];
}
// Private modes, all of these can be safely ignored
if (Regex.Match(text, @"\x1B\[?(25l|25h|47l|47h|1049h|1049l)") is
{Success: true} match3)
{
// Remove the sequence from the text
text = text[..match3.Index] + text[(match3.Index + match3.Length)..];
}
}
// If text still contains escape sequences, remove them
if (text.Contains('\u001b'))
{
Debug.WriteLine($"Removing unhandled escape sequences: {text.ToRepr()}");
text = AnsiParser.AnsiEscapeSequenceRegex().Replace(text, "");
}
return new ProcessOutput
{
RawText = originalText,
@ -104,6 +149,7 @@ public readonly record struct ProcessOutput
IsStdErr = isStdErr,
CarriageReturn = clearLines,
CursorUp = cursorUp,
AnsiCommand = ansiCommands,
};
}
}

Loading…
Cancel
Save