Avalonia Logging Component Implementation and Optimization Guide

Avalonia Logging Component Implementation and Optimization Guide

In-depth analysis of the implementation scheme of the Avalonia-based logging component, exploring the dual output mechanism of interface and file, and proposing points for optimization and improvement

Last updated 7/3/2025 10:24 PM
沙漠尽头的狼
7 min read
Category
Avalonia UI
Topic
Avalonia UI Open Source Project C# Open Source Project
Tags
.NET C# Avalonia UI Open Source Project Open Source

Background

Avalonia currently lacks a rich text box for log output display, but the SelectableTextBlock control can be used as a replacement. Below is a log component implemented by the site owner:

It can display log timestamps, log levels, log details, etc. In addition to outputting to the UI, the backend can also persist logs to a text file. For more extensive log information display, you can extend it yourself. First, we will explain the implementation process, then point out existing issues. PRs are welcome.

Usage

Install:

NuGet\Install-Package CodeWF.LogViewer.Avalonia -Version 1.0.10.2

View usage:

xmlns:log="https://codewf.com"

<log:LogView />

Log output:

Logger.Debug("Debug log");
Logger.Info("Info log");
Logger.Warn("Warning log");
Logger.Error("Error log");
Logger.Fatal("Fatal log");

Implementation

Only the key parts of the code are mentioned. For the full code, please browse the CodeWF.LogViewer repository.

The program outputs logs via the Logger class, which caches log entries in a ConcurrentQueue<LogInfo> Logs collection. The Logger class is defined as follows:

public static class Logger
{
    public static LogType Level = LogType.Info;
    public static string LogDir = AppDomain.CurrentDomain.BaseDirectory;
    internal static readonly ConcurrentQueue<LogInfo> Logs = new();

    public static void RecordToFile()
    {
        Task.Run(async () =>
        {
            while (true)
            {
                while (TryDequeue(out var log))
                {
                    var content =
                        $"{log.RecordTime}: {log.Level.Description()} {log.Description}{Environment.NewLine}";
                    AddLogToFile(content);
                }

                await Task.Delay(TimeSpan.FromMilliseconds(100));
            }
        });
    }

    public static bool TryDequeue(out LogInfo info)
    {
        return Logs.TryDequeue(out info);
    }

    public static void Log(int type, string content)
    {
        var logType = (LogType)type;
        if (Level > logType) return;
        Logs.Enqueue(new LogInfo(logType, content));
    }

    public static void Debug(string content)
    {
        if (Level <= LogType.Debug)
        {
            Logs.Enqueue(new LogInfo(LogType.Debug, content));
        }
    }

    public static void Info(string content)
    {
        if (Level <= LogType.Info)
        {
            Logs.Enqueue(new LogInfo(LogType.Info, content));
        }
    }

    public static void Warn(string content)
    {
        if (Level <= LogType.Warn)
        {
            Logs.Enqueue(new LogInfo(LogType.Warn, content));
        }
    }

    public static void Error(string content, Exception? ex = null)
    {
        if (Level > LogType.Error) return;

        var msg = ex == null ? content : $"{content}\r\n{ex.ToString()}";

        Logs.Enqueue(new LogInfo(LogType.Error, msg));
    }

    public static void Fatal(string content, Exception? ex = null)
    {
        if (Level > LogType.Fatal) return;

        var msg = ex == null ? content : $"{content}\r\n{ex.ToString()}";

        Logs.Enqueue(new LogInfo(LogType.Fatal, msg));
    }

    public static void AddLogToFile(string msg)
    {
        try
        {
            var logFolder = System.IO.Path.Combine(LogDir, "Log");
            if (!Directory.Exists(logFolder))
            {
                Directory.CreateDirectory(logFolder);
            }

            var logFileName = System.IO.Path.Combine(logFolder, $"Log_{DateTime.Now:yyyy_MM_dd}.log");
            File.AppendAllText(logFileName, msg);
        }
        catch
        {
            // ignored
        }
    }
}

Only outputting logs to a text file

If you only want to output logs to a text file without displaying them in the UI, you need to actively call the Logger.RecordToFile() method to periodically check for log output.

Outputting logs to both a text file and the view

Prerequisite: There is no need to call the Logger.RecordToFile() method here

Let's first look at the view LogView.axaml. This part uses a ScrollViewer wrapping a SelectableTextBlock to enable log scrolling and selection/copying of log text:

<ScrollViewer
    x:Name="LogScrollViewer"
    HorizontalScrollBarVisibility="Auto"
    PointerPressed="LogScrollViewer_OnPointerPressed"
    VerticalScrollBarVisibility="Auto">
    <SelectableTextBlock
        x:Name="LogTextView"
        TextAlignment="Start"
        TextWrapping="Wrap">
        <SelectableTextBlock.ContextMenu>
            <ContextMenu x:Name="LogContextMenu">
                <MenuItem Click="Copy_OnClick" Header="Copy" />
                <MenuItem Click="Clear_OnClick" Header="Clear" />
                <MenuItem Click="Location_OnClick" Header="View log" />
            </ContextMenu>
        </SelectableTextBlock.ContextMenu>
    </SelectableTextBlock>
</ScrollViewer>

In LogView.axaml.cs, the RecordLog() method is called to read cached logs periodically, then calls the LogNotifyHandler method to write to the UI, and calls Logger.AddLogToFile to write to a text file. The code is not extensive; below is the core part:

partial class LogView : UserControl
{
    // ...
    private void RecordLog()
    {
        if (_isRecording) return;

        _isRecording = true;

        Task.Run(async () =>
        {
            while (true)
            {
                while (Logger.TryDequeue(out var log)) LogNotifyHandler(log);

                await Task.Delay(TimeSpan.FromMilliseconds(100));
            }
        });
    }

    private void LogNotifyHandler(LogInfo logInfo)
    {
        if (Logger.Level > logInfo.Level) return;

        _synchronizationContext.Post(o =>
        {
            var inlines = _textView.Inlines;
            try
            {
                if (inlines?.Count > MaxCount)
                {
                    for (var i = 0; i < 3; i++)
                    {
                        var needRemoveElement = inlines.First();
                        if (needRemoveElement != null)
                        {
                            inlines.Remove(needRemoveElement);
                        }
                    }
                }

                var start = _textView.Text.Length;

                inlines?.Add(
                    new Run($"{logInfo.RecordTime}")
                    {
                        Foreground = new SolidColorBrush(Color.Parse("#8C8C8C")),
                        BaselineAlignment = BaselineAlignment.Center
                    });
                inlines?.Add(GetLevelInline(logInfo.Level));
                inlines?.Add(new Run(logInfo.Description)
                {
                    Foreground = new SolidColorBrush(Color.Parse("#262626")),
                    BaselineAlignment = BaselineAlignment.Center
                });
                inlines?.Add(new Run(Environment.NewLine));

                Logger.AddLogToFile(
                    $"{logInfo.RecordTime}: {logInfo.Level.Description()} {logInfo.Description}{Environment.NewLine}");

                _textView.SelectionStart = start;
                _textView.SelectionEnd = _textView.Text.Length;
                _scrollViewer.ScrollToEnd();
            }
            catch
            {
                // ignored
            }
        }, null);
    }

    private Span GetLevelInline(LogType level)
    {
        var content = level.Description();

        // Create a zero-width transparent text for copying
        // TODO: Copy still has issues, misalignment
        var zeroWidthText = new Run($"【{content}】")
        {
            Foreground = Brushes.Transparent, FontSize = 0.001
        };

        // Visually displayed text, not used for copying
        var border = new Border
        {
            BorderBrush = GetLevelForeground(level),
            Background = GetLevelBackground(level),
            BorderThickness = new Thickness(1),
            CornerRadius = new CornerRadius(2),
            Padding = new Thickness(8, 0),
            Margin = new Thickness(8, 2),
            VerticalAlignment = VerticalAlignment.Center,
            IsHitTestVisible = false,
            Child = new TextBlock
            {
                Text = content,
                Foreground = GetLevelForeground(level),
                IsHitTestVisible = false
            }
        };
        var levelSpan = new Span();
        levelSpan.Inlines.Add(zeroWidthText);
        levelSpan.Inlines.Add(border);
        return levelSpan;
    }
    // ...
}

The code for retrieving the foreground and background colors based on log type is omitted above as it is not important.

Existing Issues

Looking at the GetLevelInline method above, it generates a log level block using a Border wrapping the log level description (Debug, Error, etc.), achieving a bordered style for log types. However, there is a problem with copying:

Select the block 7:56 Debug Module and press Ctrl + C to copy, then paste into Notepad. The copied result is 调试】Module Name A-, a clear misalignment issue.

The copied content should be the text, but Border is not copyable, so the comment Create a zero-width transparent text for copying was added in the code:

// Create a zero-width transparent text for copying
// TODO: Copy still has issues, misalignment
var zeroWidthText = new Run($"【{content}】")
{
    Foreground = Brushes.Transparent, FontSize = 0.001
};

I won't go into details. Does anyone have a solution? Awaiting a PR from a kind soul. Thank you.

Summary

This article mainly seeks PRs. If this component is useful to you, you are welcome to use it. Repository address:

In the next post, we will share the implementation of custom TabItem borders:

Keep Exploring

Related Reading

More Articles
Same category / Same tag 8/9/2025

Lang.Avalonia: Avalonia multi-language solution, seamlessly supports three formats: Resx/XML/JSON

This is a multi-language management library designed specifically for the Avalonia framework. It reconstructs multi-language support logic through a plugin-based architecture, not only supporting traditional Resx resource files but also adding support for XML and JSON formats, while providing type-safe resource references and dynamic language switching, making multi-language development simpler and more efficient.

Continue Reading