using System.Collections.Concurrent; using System.Diagnostics; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Media; using System.Windows.Threading; using XFEToolBox.Client.Core.Console; namespace XFEToolBox.Client.Utilities; /// /// Buffers console writes from any thread and renders them in bounded UI-thread batches. /// internal sealed class BufferedConsoleRenderer { private const int LinesPerTextBlock = 128; private const int MaxEntriesPerFlush = 2048; private const int FlushTimeBudgetMilliseconds = 8; private static readonly ConcurrentDictionary BrushCache = new(); private readonly Dispatcher dispatcher; private readonly ItemsControl outputControl; private readonly Func maxLineProvider; private readonly ConsoleOutputBuffer outputBuffer = new(); private readonly Queue textBlocks = new(); private ConsoleTextBlock? currentTextBlock; private int flushScheduled; private int renderedLineCount; public BufferedConsoleRenderer(Dispatcher dispatcher, ItemsControl outputControl, Func maxLineProvider) { this.dispatcher = dispatcher; this.outputControl = outputControl; this.maxLineProvider = maxLineProvider; } /// /// Raised on the UI thread after visible content changes. /// public event Action? ContentChanged; public int PendingCount => outputBuffer.Count; public int RenderedLineCount => renderedLineCount; public void Append(string message, Color defaultColor, bool isLineEnd, bool forceNewLine = false) { outputBuffer.Enqueue(message, new RenderMetadata(defaultColor, null), isLineEnd, forceNewLine); ScheduleFlush(); } public void Append(Func messageFactory, Color defaultColor, bool isLineEnd, bool forceNewLine = false) { outputBuffer.Enqueue(messageFactory, new RenderMetadata(defaultColor, null), isLineEnd, forceNewLine); ScheduleFlush(); } public Task AppendAsync(string message, Color defaultColor, bool isLineEnd, bool forceNewLine = false) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); outputBuffer.Enqueue(message, new RenderMetadata(defaultColor, completion), isLineEnd, forceNewLine); ScheduleFlush(); return completion.Task; } public void Clear() { if (!dispatcher.CheckAccess()) { dispatcher.Invoke(Clear); return; } foreach (var discardedOutput in outputBuffer.Clear()) discardedOutput.Metadata.Completion?.TrySetResult(); textBlocks.Clear(); currentTextBlock = null; renderedLineCount = 0; outputControl.Items.Clear(); ContentChanged?.Invoke(false); } private void ScheduleFlush() { if (dispatcher.HasShutdownStarted || dispatcher.HasShutdownFinished) return; if (Interlocked.CompareExchange(ref flushScheduled, 1, 0) != 0) return; try { _ = dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(FlushPending)); } catch (InvalidOperationException) { Interlocked.Exchange(ref flushScheduled, 0); } } private void FlushPending() { dispatcher.VerifyAccess(); Interlocked.Exchange(ref flushScheduled, 0); var stopwatch = Stopwatch.StartNew(); var completions = new List(); var plainText = new StringBuilder(); TextBlock? plainTextTarget = null; Color plainTextColor = default; var renderedAny = false; var processedCount = 0; void FlushPlainText() { if (plainText.Length == 0 || plainTextTarget is null) return; plainTextTarget.Inlines.Add(new Run(plainText.ToString()) { Foreground = GetBrush(plainTextColor) }); plainText.Clear(); plainTextTarget = null; } while (processedCount < MaxEntriesPerFlush && outputBuffer.TryDequeue(out var output)) { processedCount++; if (!outputBuffer.IsCurrent(output)) { output.Metadata.Completion?.TrySetResult(); continue; } var startsNewTextBlock = EnsureTargetLine(output.StartsNewLine); var linePrefix = output.StartsNewLine && !startsNewTextBlock ? "\n" : string.Empty; if (IsPlainText(output.Text)) { var target = currentTextBlock!.TextBlock; if (plainTextTarget != target || plainTextColor != output.Metadata.DefaultColor) FlushPlainText(); plainTextTarget = target; plainTextColor = output.Metadata.DefaultColor; plainText.Append(linePrefix); plainText.Append(output.Text); } else { FlushPlainText(); if (linePrefix.Length != 0) currentTextBlock!.TextBlock.Inlines.Add(new LineBreak()); AppendDecoratedText(currentTextBlock!.TextBlock, output.Text, output.Metadata.DefaultColor); } if (output.Metadata.Completion is not null) completions.Add(output.Metadata.Completion); renderedAny = true; if ((processedCount & 63) == 0 && stopwatch.ElapsedMilliseconds >= FlushTimeBudgetMilliseconds) break; } FlushPlainText(); if (renderedAny) { TrimOldOutput(); ContentChanged?.Invoke(renderedLineCount > 0); } foreach (var completion in completions) completion.TrySetResult(); if (!outputBuffer.IsEmpty) ScheduleFlush(); } private bool EnsureTargetLine(bool startsNewLine) { if (currentTextBlock is not null && !startsNewLine) return false; var maxLines = GetMaxLines(); var blockCapacity = Math.Min(LinesPerTextBlock, maxLines); var startsNewTextBlock = currentTextBlock is null || currentTextBlock.LineCount >= blockCapacity; if (startsNewTextBlock) { var textBlock = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = 13, FontFamily = new FontFamily("Consolas") }; currentTextBlock = new ConsoleTextBlock(textBlock); textBlocks.Enqueue(currentTextBlock); outputControl.Items.Add(textBlock); } currentTextBlock!.LineCount++; renderedLineCount++; return startsNewTextBlock; } private void TrimOldOutput() { var maxLines = GetMaxLines(); while (renderedLineCount > maxLines && textBlocks.Count > 1) { var firstBlock = textBlocks.Dequeue(); renderedLineCount -= firstBlock.LineCount; outputControl.Items.Remove(firstBlock.TextBlock); } } private static bool IsPlainText(string text) => text.IndexOf('[') < 0; private static void AppendDecoratedText(TextBlock textBlock, string message, Color defaultColor) { try { var spans = DecoratedTextConverter.ConvertText(message, defaultColor); if (spans.Count == 0) { textBlock.Inlines.Add(new Run(message) { Foreground = GetBrush(defaultColor) }); return; } foreach (var inlineObject in DecoratedTextConverter.ConvertToInlineList(spans, message)) { if (inlineObject is Inline inline) textBlock.Inlines.Add(inline); else textBlock.Inlines.Add((UIElement)inlineObject); } } catch (Exception) { // A malformed decoration must not stop the output pump. Show the raw // message instead, matching how an ordinary console handles the text. textBlock.Inlines.Add(new Run(message) { Foreground = GetBrush(defaultColor) }); } } private static Brush GetBrush(Color color) { if (color == Colors.White) return Brushes.White; return BrushCache.GetOrAdd(color, static value => { var brush = new SolidColorBrush(value); brush.Freeze(); return brush; }); } private int GetMaxLines() => Math.Max(1, maxLineProvider()); private sealed class ConsoleTextBlock(TextBlock textBlock) { public TextBlock TextBlock { get; } = textBlock; public int LineCount { get; set; } } private readonly record struct RenderMetadata(Color DefaultColor, TaskCompletionSource? Completion); }