This article discusses the implementation of a reusable, hassle free customized text box for Windows Form create to capture DEBUG and TRACE output with minimum impact on performance and memory consumption.
Introduction
Do you need to add a log window for your WinForm application? We created an easy to use user control to capture traces from application with thread support.
The latest source code is available at GitHub.
Background
When working with application development with WinForms, most of the time, we will want to capture output from System.Diagnostics.Debug
and System.Diagnostics.Trace
during development as well as after released, where the log is useful to understand the issue reported by user. The need increases with the complexity of the application. For applications which perform complex processing, a log window is a quick and useful indicator to tell the user that the application is running, not freezed so they don't just kill it.
Hence, we decided to create a custom control where we can just drag and drop to form to start logging immediately every time we start a new project without rewrite or copy a single line of code. These controls are named as DiagnosticsTextBox
and DiagnosticsRichTextBox
which is part of the CodeArtEng.Diagnostics
NuGet package.
Using the Control
The control is released in GitHub and published in NuGet.
- Include NuGet package CodeArtEng.Diagnostics to your WinForm project.
- Drag
DiagnosticsTextBox
or DiagnosticsRichTextBox
to your project.
Architecture
DiagnosticsTextBox
and DiagnosticsRichTextBox
are created with same design. We will describe the implementation based on DiagnosticsTextBox
which shows plain text and describes additional methods and features included with DiagnosticsRichTextBox
which support color formatting.
Classes Overview
System.Diagnostcis.TraceListener
: Provides the abstract
base class for the listeners who monitor trace and debug output. CodeArtEng.Diagnostics.TraceLogger
: Derived from TraceListener
, provide implementation of Write
and WriteLine
methods. Provide callback function for DiagnosticsTextBox
. CodeArtEng.Diagnostics.TraceFileWritter
: Derived from TraceListener
, implement logging to file. This class can also be used on it own. CodeArtEng.Diagnostics.DiagnosticsTextBox
: TextBox
control to display DEBUG
and TRACE
messages.
Behind the Scenes
Capture and Parsing Message
TraceLogger is handling all the message received from TraceListener
, process and pass it over to DiagnosticsTextBox
and TraceFileWritter
. Three callback functions implemented in TraceLogger
: OnMessageReceived
, OnWrite
, OnFlush.
Class TraceLogger
public override void Write(string message)
{
OnMessageReceived(ref message);
if (string.IsNullOrEmpty(message)) return;
message = ParseMessage(message);
OnWrite(message);
IsNewLine = false;
}
Class DiagnosticsTextBox
private void Tracer_OnMessageReceived(ref string message)
{
if(MessageReceived != null)
{
TextEventArgs eArg = new TextEventArgs() { Message = message };
MessageReceived.Invoke(this, eArg);
message = eArg.Message;
}
}
[DisplayName("MessageReceived")]
public event EventHandler<TextEventArgs> MessageReceived;
When message is received from Write
or WriteLine
, the raw message is forwarded to parent class by OnMessageReceived
. In DiagnosticsTextBox
class, the message is then forward to the next level by MessageReceived
event. Developer can make use of this event to react or filter message when necessary.
The message is then handled by ParseMessage
to perform formatting of each and every incoming messages in Write
and WriteLine
methods before writing it to file or text box.
private string ParseMessage(string message)
{
string dateTimeStr = ShowTimeStamp ? AppendDateTime() : string.Empty;
string result = IsNewLine ? dateTimeStr : string.Empty;
if (message.Contains("\r") || message.Contains("\n"))
{
message = message.Replace("\n", "\r");
message = message.Replace("\r\r", "\r");
string newLineFiller = new string(' ', dateTimeStr.Length);
string[] multiLineMessage = message.Split('\r');
result += multiLineMessage[0].Trim() + NewLineDelimiter;
foreach (string msg in multiLineMessage.Skip(1))
result += newLineFiller + msg.Trim() + NewLineDelimiter;
result = result.TrimEnd();
}
else result += message;
return result;
}
If ShowTimeStamp
is enabled, time stamp will be appended to message when ParseMessage
is called. The format of the timestamp is defined in TimeStampFormat
properties using the .NET string format.
private string AppendDateTime()
{
switch (TimeStampStyle)
{
case TraceTimeStampStyle.DateTimeString:
return "[" + DateTime.Now.ToString(TimeStampFormat) + "] ";
case TraceTimeStampStyle.TickCount: return "[" + DateTime.Now.Ticks.ToString() + "] ";
}
return "-";
}
Besides, ParseMessage
also takes care of alignment of multi-line messages when time stamp is added. Text alignment is done by insert leading space by counting characters occupied by time stamp. The alignment works best with fixed width font, such as Courier New.
Display Messages in DiagnosticsTextBox
We want to have the text box updated with latest messages as soon as possible but not blocking the Main thread until Main Form become not responding. Considering that Write
and WriteLine
will be called from either Main or Worker thread, we can't write the message to TextBox
directly which can only be updated by MainThread
.
A MessageBuffer
is introduced in DiagnosticsTextBox
to capture all incoming messages and update to text box control at defined timer interval, value configurable by RefreshInterval
property. Every time when timer ticks, the text box is updated with messages from MessageBuffer
. A lock is added to prevent MessageBuffer
being modified simultaneously.
private void refreshTimer_Tick(object sender, EventArgs e)
{
lock (LockObject)
{
if (MessageBuffer.Length == 0) return;
this.AppendText(MessageBuffer);
MessageBuffer = "";
Memory Consumption
One issue we encountered when we deployed the first revision of the DiagnosticTextBox
is memory consumption increased overtime. For application which runs for hours and days, it eventually triggers Memory overflow exception. We then noticed that this was caused by the text box which had been filled up by all the log messages.
To improve the performance and minimize total memory consumption, we added a property named DisplayBufferSize
to define maximum lines to display in text box. This is part of the refreshTimer_Tick
method. We use Array.Copy
to maximize the performance. User can still choose to set DisplayBufferSize
to 0 to always display all messages.
if (DisplayBufferSize <= 0) return;
if (Lines.Length > DisplayBufferSize)
{
string[] data = new string[DisplayBufferSize];
Array.Copy(Lines, Lines.Length - DisplayBufferSize, data, 0, DisplayBufferSize);
Lines = data;
}
SelectionStart = Text.Length;
ScrollToCaret();
}
}
Formatting with Color
The difference between DiagnosticsTextBox
and DiagnosticsRichTextBox
is the later one "Rich" in color. An additional method named AddFormattingRule
to define font color of specific line based on matching string in a dictionary.
public void AddFormattingRule(string containString, Color color)
{
if (SyntaxTable.ContainsKey(containString)) return;
SyntaxTable.Add(containString, color);
}
Whenever TextChanged
event trigger, the new added lines is scanned and format based on formatting rules. For each line, this might not be the most efficient implementation, but it's simple and easily readable without the need to manipulate RTF format in rich text box directly.
Notice that we use LastSelection
to keep track of last updated line in such that we can skip lines which we already processed on next TextChanged
event. Besides, an Updating
flag is used to prevent recursive call when text box content is being updated in FormatText
method.
private void DiagnosticsRichTextBox_TextChanged(object sender, EventArgs e)
{
FormatText();
}
private void FormatText()
{
if (Updating) return;
Updating = true;
try
{
int startLine = GetLineFromCharIndex(LastSelection);
for (int x = startLine; x < Lines.Length; x++)
{
int lineStart = GetFirstCharIndexFromLine(x);
int lineEnd = GetFirstCharIndexFromLine(x + 1) - 1;
SelectionStart = lineStart;
SelectionLength = lineEnd < 0 ? 0 : lineEnd - lineStart + 1;
if (AutoResetFormat)
SelectionColor = LastFontColor = ForeColor;
else
SelectionColor = LastFontColor;
foreach (KeyValuePair<string, Color> entry in SyntaxTable)
{
if (Lines[x].Contains(entry.Key))
{
SelectionColor = LastFontColor = entry.Value;
break;
}
}
}
SelectionStart = LastSelection = Text.Length;
ScrollToCaret();
}
finally { Updating = false; }
}
Unfortunately, there is no easy way to remove lines from rich text box without affecting it previous formatting. When number of lines in rich text box reached defined DisplayBufferSize
, the entire rich text box had to be scanned and update format once again.
if (DisplayBufferSize <= 0) return;
if (Lines.Length > DisplayBufferSize)
{
string[] data = new string[DisplayBufferSize];
Array.Copy(Lines, Lines.Length - DisplayBufferSize, data, 0, DisplayBufferSize);
Lines = data;
LastSelection = 0;
FormatText();
}
Points of Interest
One challenge we faced in developing this control is we can't call Write
and WriteLine
in any of the classes which will caused recursive call. We can only make use of breakpoint in Visual Studio to step through the code to identify the bug. We hope that this tool will benefit all fellow developers who work with WinForms for faster development and debugging.
History
- 25th June, 2020: Initial version