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:
- CodeWF.LogViewer: https://github.com/dotnet9/CodeWF.LogViewer
In the next post, we will share the implementation of custom TabItem borders:
