XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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;

/// <summary>
/// Buffers console writes from any thread and renders them in bounded UI-thread batches.
/// </summary>
internal sealed class BufferedConsoleRenderer
{
    private const int LinesPerTextBlock = 128;
    private const int MaxEntriesPerFlush = 2048;
    private const int FlushTimeBudgetMilliseconds = 8;

    private static readonly ConcurrentDictionary<Color, Brush> BrushCache = new();

    private readonly Dispatcher dispatcher;
    private readonly ItemsControl outputControl;
    private readonly Func<int> maxLineProvider;
    private readonly ConsoleOutputBuffer<RenderMetadata> outputBuffer = new();
    private readonly Queue<ConsoleTextBlock> textBlocks = new();

    private ConsoleTextBlock? currentTextBlock;
    private int flushScheduled;
    private int renderedLineCount;

    public BufferedConsoleRenderer(Dispatcher dispatcher, ItemsControl outputControl, Func<int> maxLineProvider)
    {
        this.dispatcher = dispatcher;
        this.outputControl = outputControl;
        this.maxLineProvider = maxLineProvider;
    }

    /// <summary>
    /// Raised on the UI thread after visible content changes.
    /// </summary>
    public event Action<bool>? 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<bool, string> 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<TaskCompletionSource>();
        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);
}