|
|
|
@ -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> |
|
|
|
|