Elegant Log Window in WinForms C#
I am looking for ideas on an efficient way to implement a log window for a windows forms application. In the past I have implemented several using TextBox and RichTextBox but I am still not totally satisfied with the functionality.
This log is intended to provide the user with a recent history of various events, primarily used in data-gathering applications where one might be curious how a particular transaction completed. In this case, the log need not be permanent nor saved to a file.
First, some proposed requirements:
- Efficient and fast; if hundreds of lines are written to the log in quick succession, it needs to consume minimal resources and time.
- Be able to offer a variable scrollback of up to 2000 lines or so. Anything longer is unnecessary.
- Highlighting and color are preferred. Font effects not required.
- Automatically trim lines as the scrollback limit is reached.
- Automatically scroll as new data is added.
- Bonus but not required: Pause auto-scrolling during manual interaction such as if the user is browsing the history.
What I have been using so far to write and trim the log:
I use the following code (which I call from other threads):
// rtbLog is a RichTextBox
// _MaxLines is an int
public void AppendLog(string s, Color c, bool bNewLine)
{
if (rtbLog.InvokeRequired)
{
object[] args = { s, c, bNewLine };
rtbLog.Invoke(new AppendLogDel(AppendLog), args);
return;
}
try
{
rtbLog.SelectionColor = c;
rtbLog.AppendText(s);
if (bNewLine) rtbLog.AppendText(Environment.NewLine);
TrimLog();
rtbLog.SelectionStart = rtbLog.TextLength;
rtbLog.ScrollToCaret();
rtbLog.Update();
}
catch (Exception exc)
{
// exception handling
}
}
private void TrimLog()
{
try
{
// Extra lines as buffer to save time
if (rtbLog.Lines.Length < _MaxLines + 10)
{
return;
}
else
{
string[] sTemp = rtxtLog.Lines;
string[] sNew= new string[_MaxLines];
int iLineOffset = sTemp.Length - _MaxLines;
for (int n = 0; n < _MaxLines; n++)
{
sNew[n] = sTemp[iLineOffset];
iLineOffset++;
}
rtbLog.Lines = sNew;
}
}
catch (Exception exc)
{
// exception handling
}
}
The problem with this approach is that whenever TrimLog is called, I lose color formatting. With a regular TextBox this works just fine (with a bit of modification of course).
Searches for a solution to this have never been really satisfactory. Some suggest to trim the excess by character count instead of line count in a RichTextBox. I've also seen ListBoxes used, but haven't successfully tried it.
I recommend that you don't use a control as your log at all. Instead write a log collection class that has the properties you desire (not including the display properties).
Then write the little bit of code that is needed to dump that collection to a variety of user interface elements. Personally, I would put SendToEditControl
and SendToListBox
methods into my logging object. I would probably add filtering capabilities to these methods.
You can update the UI log only as often as it makes sense, giving you the best possible performance, and more importantly, letting you reduce the UI overhead when the log is changing rapidly.
The important thing is not to tie your logging to a piece of UI, that's a mistake. Someday you may want to run headless.
In the long run, a good UI for a logger is probably a custom control. But in the short run, you just want to disconnect your logging from any specific piece of UI.
Here is something I threw together based on a much more sophisticated logger I wrote a while ago.
This will support color in the list box based on log level, supports Ctrl+V and Right-Click for copying as RTF, and handles logging to the ListBox from other threads.
You can override the number of lines retained in the ListBox (2000 by default) as well as the message format using one of the constructor overloads.
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Threading;
using System.Text;
namespace StackOverflow
{
public partial class Main : Form
{
public static ListBoxLog listBoxLog;
public Main()
{
InitializeComponent();
listBoxLog = new ListBoxLog(listBox1);
Thread thread = new Thread(LogStuffThread);
thread.IsBackground = true;
thread.Start();
}
private void LogStuffThread()
{
int number = 0;
while (true)
{
listBoxLog.Log(Level.Info, "A info level message from thread # {0,0000}", number++);
Thread.Sleep(2000);
}
}
private void button1_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Debug, "A debug level message");
}
private void button2_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Verbose, "A verbose level message");
}
private void button3_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Info, "A info level message");
}
private void button4_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Warning, "A warning level message");
}
private void button5_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Error, "A error level message");
}
private void button6_Click(object sender, EventArgs e)
{
listBoxLog.Log(Level.Critical, "A critical level message");
}
private void button7_Click(object sender, EventArgs e)
{
listBoxLog.Paused = !listBoxLog.Paused;
}
}
public enum Level : int
{
Critical = 0,
Error = 1,
Warning = 2,
Info = 3,
Verbose = 4,
Debug = 5
};
public sealed class ListBoxLog : IDisposable
{
private const string DEFAULT_MESSAGE_FORMAT = "{0} [{5}] : {8}";
private const int DEFAULT_MAX_LINES_IN_LISTBOX = 2000;
private bool _disposed;
private ListBox _listBox;
private string _messageFormat;
private int _maxEntriesInListBox;
private bool _canAdd;
private bool _paused;
private void OnHandleCreated(object sender, EventArgs e)
{
_canAdd = true;
}
private void OnHandleDestroyed(object sender, EventArgs e)
{
_canAdd = false;
}
private void DrawItemHandler(object sender, DrawItemEventArgs e)
{
if (e.Index >= 0)
{
e.DrawBackground();
e.DrawFocusRectangle();
LogEvent logEvent = ((ListBox)sender).Items[e.Index] as LogEvent;
// SafeGuard against wrong configuration of list box
if (logEvent == null)
{
logEvent = new LogEvent(Level.Critical, ((ListBox)sender).Items[e.Index].ToString());
}
Color color;
switch (logEvent.Level)
{
case Level.Critical:
color = Color.White;
break;
case Level.Error:
color = Color.Red;
break;
case Level.Warning:
color = Color.Goldenrod;
break;
case Level.Info:
color = Color.Green;
break;
case Level.Verbose:
color = Color.Blue;
break;
default:
color = Color.Black;
break;
}
if (logEvent.Level == Level.Critical)
{
e.Graphics.FillRectangle(new SolidBrush(Color.Red), e.Bounds);
}
e.Graphics.DrawString(FormatALogEventMessage(logEvent, _messageFormat), new Font("Lucida Console", 8.25f, FontStyle.Regular), new SolidBrush(color), e.Bounds);
}
}
private void KeyDownHandler(object sender, KeyEventArgs e)
{
if ((e.Modifiers == Keys.Control) && (e.KeyCode == Keys.C))
{
CopyToClipboard();
}
}
private void CopyMenuOnClickHandler(object sender, EventArgs e)
{
CopyToClipboard();
}
private void CopyMenuPopupHandler(object sender, EventArgs e)
{
ContextMenu menu = sender as ContextMenu;
if (menu != null)
{
menu.MenuItems[0].Enabled = (_listBox.SelectedItems.Count > 0);
}
}
private class LogEvent
{
public LogEvent(Level level, string message)
{
EventTime = DateTime.Now;
Level = level;
Message = message;
}
public readonly DateTime EventTime;
public readonly Level Level;
public readonly string Message;
}
private void WriteEvent(LogEvent logEvent)
{
if ((logEvent != null) && (_canAdd))
{
_listBox.BeginInvoke(new AddALogEntryDelegate(AddALogEntry), logEvent);
}
}
private delegate void AddALogEntryDelegate(object item);
private void AddALogEntry(object item)
{
_listBox.Items.Add(item);
if (_listBox.Items.Count > _maxEntriesInListBox)
{
_listBox.Items.RemoveAt(0);
}
if (!_paused) _listBox.TopIndex = _listBox.Items.Count - 1;
}
private string LevelName(Level level)
{
switch (level)
{
case Level.Critical: return "Critical";
case Level.Error: return "Error";
case Level.Warning: return "Warning";
case Level.Info: return "Info";
case Level.Verbose: return "Verbose";
case Level.Debug: return "Debug";
default: return string.Format("<value={0}>", (int)level);
}
}
private string FormatALogEventMessage(LogEvent logEvent, string messageFormat)
{
string message = logEvent.Message;
if (message == null) { message = "<NULL>"; }
return string.Format(messageFormat,
/* {0} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss.fff"),
/* {1} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss"),
/* {2} */ logEvent.EventTime.ToString("yyyy-MM-dd"),
/* {3} */ logEvent.EventTime.ToString("HH:mm:ss.fff"),
/* {4} */ logEvent.EventTime.ToString("HH:mm:ss"),
/* {5} */ LevelName(logEvent.Level)[0],
/* {6} */ LevelName(logEvent.Level),
/* {7} */ (int)logEvent.Level,
/* {8} */ message);
}
private void CopyToClipboard()
{
if (_listBox.SelectedItems.Count > 0)
{
StringBuilder selectedItemsAsRTFText = new StringBuilder();
selectedItemsAsRTFText.AppendLine(@"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset0 Courier;}}");
selectedItemsAsRTFText.AppendLine(@"{\colortbl;\red255\green255\blue255;\red255\green0\blue0;\red218\green165\blue32;\red0\green128\blue0;\red0\green0\blue255;\red0\green0\blue0}");
foreach (LogEvent logEvent in _listBox.SelectedItems)
{
selectedItemsAsRTFText.AppendFormat(@"{{\f0\fs16\chshdng0\chcbpat{0}\cb{0}\cf{1} ", (logEvent.Level == Level.Critical) ? 2 : 1, (logEvent.Level == Level.Critical) ? 1 : ((int)logEvent.Level > 5) ? 6 : ((int)logEvent.Level) + 1);
selectedItemsAsRTFText.Append(FormatALogEventMessage(logEvent, _messageFormat));
selectedItemsAsRTFText.AppendLine(@"\par}");
}
selectedItemsAsRTFText.AppendLine(@"}");
System.Diagnostics.Debug.WriteLine(selectedItemsAsRTFText.ToString());
Clipboard.SetData(DataFormats.Rtf, selectedItemsAsRTFText.ToString());
}
}
public ListBoxLog(ListBox listBox) : this(listBox, DEFAULT_MESSAGE_FORMAT, DEFAULT_MAX_LINES_IN_LISTBOX) { }
public ListBoxLog(ListBox listBox, string messageFormat) : this(listBox, messageFormat, DEFAULT_MAX_LINES_IN_LISTBOX) { }
public ListBoxLog(ListBox listBox, string messageFormat, int maxLinesInListbox)
{
_disposed = false;
_listBox = listBox;
_messageFormat = messageFormat;
_maxEntriesInListBox = maxLinesInListbox;
_paused = false;
_canAdd = listBox.IsHandleCreated;
_listBox.SelectionMode = SelectionMode.MultiExtended;
_listBox.HandleCreated += OnHandleCreated;
_listBox.HandleDestroyed += OnHandleDestroyed;
_listBox.DrawItem += DrawItemHandler;
_listBox.KeyDown += KeyDownHandler;
MenuItem[] menuItems = new MenuItem[] { new MenuItem("Copy", new EventHandler(CopyMenuOnClickHandler)) };
_listBox.ContextMenu = new ContextMenu(menuItems);
_listBox.ContextMenu.Popup += new EventHandler(CopyMenuPopupHandler);
_listBox.DrawMode = DrawMode.OwnerDrawFixed;
}
public void Log(string message) { Log(Level.Debug, message); }
public void Log(string format, params object[] args) { Log(Level.Debug, (format == null) ? null : string.Format(format, args)); }
public void Log(Level level, string format, params object[] args) { Log(level, (format == null) ? null : string.Format(format, args)); }
public void Log(Level level, string message)
{
WriteEvent(new LogEvent(level, message));
}
public bool Paused
{
get { return _paused; }
set { _paused = value; }
}
~ListBoxLog()
{
if (!_disposed)
{
Dispose(false);
_disposed = true;
}
}
public void Dispose()
{
if (!_disposed)
{
Dispose(true);
GC.SuppressFinalize(this);
_disposed = true;
}
}
private void Dispose(bool disposing)
{
if (_listBox != null)
{
_canAdd = false;
_listBox.HandleCreated -= OnHandleCreated;
_listBox.HandleCreated -= OnHandleDestroyed;
_listBox.DrawItem -= DrawItemHandler;
_listBox.KeyDown -= KeyDownHandler;
_listBox.ContextMenu.MenuItems.Clear();
_listBox.ContextMenu.Popup -= CopyMenuPopupHandler;
_listBox.ContextMenu = null;
_listBox.Items.Clear();
_listBox.DrawMode = DrawMode.Normal;
_listBox = null;
}
}
}
}
I'll store this here as a help to Future Me when I want to use a RichTextBox for logging colored lines again. The following code removes the first line in a RichTextBox:
if ( logTextBox.Lines.Length > MAX_LINES )
{
logTextBox.Select(0, logTextBox.Text.IndexOf('\n')+1);
logTextBox.SelectedRtf = "{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1053\\uc1 }";
}
It took me way too long to figure out that setting SelectedRtf to just "" didn't work, but that setting it to "proper" RTF with no text content is ok.