programing

WinForms C #의 우아한 로그 창

firstcheck 2021. 1. 15. 08:17
반응형

WinForms C #의 우아한 로그 창


Windows Forms 응용 프로그램에 대한 로그 창을 구현하는 효율적인 방법에 대한 아이디어를 찾고 있습니다. 과거에는 TextBox와 RichTextBox를 사용하여 여러 가지를 구현했지만 여전히 기능에 완전히 만족하지 않습니다.

이 로그는 특정 트랜잭션이 어떻게 완료되었는지 궁금 할 수있는 데이터 수집 응용 프로그램에서 주로 사용되는 다양한 이벤트의 최근 기록을 사용자에게 제공하기위한 것입니다. 이 경우 로그는 영구적이거나 파일에 저장할 필요가 없습니다.

첫째, 몇 가지 제안 된 요구 사항 :

  • 효율적이고 빠릅니다. 수백 줄이 연속적으로 로그에 기록되면 최소한의 리소스와 시간을 소비해야합니다.
  • 최대 2000 줄 정도의 가변 스크롤 백을 제공 할 수 있습니다. 더 이상 필요하지 않습니다.
  • 강조 표시 및 색상이 선호됩니다. 글꼴 효과가 필요하지 않습니다.
  • 스크롤 백 제한에 도달하면 자동으로 줄을 자릅니다.
  • 새 데이터가 추가되면 자동으로 스크롤됩니다.
  • 보너스이지만 필수는 아님 : 사용자가 기록을 탐색하는 경우와 같이 수동 상호 작용 중에 자동 스크롤을 일시 중지합니다.

지금까지 로그를 작성하고 정리하기 위해 사용했던 것 :

다음 코드를 사용합니다 (다른 스레드에서 호출 함).

// 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
    }
}

이 접근 방식의 문제점은 TrimLog가 호출 될 때마다 색상 서식이 손실된다는 것입니다. 일반 TextBox를 사용하면 잘 작동합니다 (물론 약간 수정).

이에 대한 해결책을 찾는 것은 결코 만족스럽지 못했습니다. 일부는 RichTextBox에서 줄 수 대신 문자 수로 초과분을 줄 이도록 제안합니다. 또한 ListBoxes가 사용되는 것을 보았지만 성공적으로 시도하지는 않았습니다.


컨트롤을 로그로 사용하지 않는 것이 좋습니다. 대신 원하는 속성이 있는 로그 컬렉션 클래스를 작성 하십시오 (디스플레이 속성은 포함하지 않음).

그런 다음 해당 컬렉션을 다양한 사용자 인터페이스 요소에 덤프하는 데 필요한 약간의 코드를 작성합니다. 개인적 으로 로깅 개체 SendToEditControlSendToListBox메서드를 넣었습니다 . 이러한 방법에 필터링 기능을 추가 할 것입니다.

가능한 한 자주 UI 로그를 업데이트하여 최상의 성능을 제공 할 수 있으며 더 중요한 것은 로그가 빠르게 변경 될 때 UI 오버 헤드를 줄일 수 있다는 것입니다.

중요한 것은 로깅을 UI에 묶지 않는 것입니다. 이는 실수입니다. 언젠가는 머리없이 달리고 싶을 수도 있습니다.

장기적으로 로거를위한 좋은 UI는 아마도 사용자 지정 컨트롤 일 것입니다. 그러나 단기적으로는 특정 UI 에서 로깅 연결을 끊고 싶을뿐입니다 .


내가 얼마 전에 쓴 훨씬 더 정교한 로거를 기반으로 한 것입니다.

이것은 로그 수준에 따라 목록 상자의 색상을 지원하고, RTF로 복사하기 위해 Ctrl + V 및 오른쪽 클릭을 지원하며, 다른 스레드에서 ListBox 로의 로깅을 처리합니다.

생성자 오버로드 중 하나를 사용하여 메시지 형식뿐만 아니라 ListBox에 유지되는 줄 수 (기본값 : 2000)를 재정의 할 수 있습니다.

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;
            }
        }
    }
}

컬러 라인을 다시 로깅하기 위해 RichTextBox를 사용하고 싶을 때 Future Me의 도움으로 여기에 저장하겠습니다. 다음 코드는 RichTextBox의 첫 번째 줄을 제거합니다.

if ( logTextBox.Lines.Length > MAX_LINES )
{
  logTextBox.Select(0, logTextBox.Text.IndexOf('\n')+1);
  logTextBox.SelectedRtf = "{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1053\\uc1 }";
}

SelectedRtf를 ""로 설정하는 것이 작동하지 않는다는 것을 알아내는 데 너무 오래 걸렸지 만 텍스트 내용이없는 "적절한"RTF로 설정하는 것은 괜찮습니다.


나는 최근에 비슷한 것을 구현했습니다. 우리의 접근 방식은 스크롤 백 레코드의 링 버퍼를 유지하고 로그 텍스트를 수동으로 칠하는 것입니다 (Graphics.DrawString 사용). 그런 다음 사용자가 뒤로 스크롤하거나 텍스트를 복사하려는 경우 일반 TextBox 컨트롤로 다시 전환되는 "일시 중지"단추가 있습니다.


ListView가 이것에 완벽하다고 말하고 (세부보기 모드에서) 몇 가지 내부 앱에서 정확히 사용합니다.

유용한 팁 : 한 번에 많은 항목을 추가 / 제거 할 것이라는 것을 알고 있다면 BeginUpdate () 및 EndUpdate ()를 사용하십시오.


강조 표시 및 색상 서식 지정을 원하면 RichTextBox를 제안합니다.

자동 스크롤을 원하면 ListBox를 사용하십시오.

두 경우 모두 선의 원형 버퍼에 바인딩하십시오.


기본 로그 창을 만드는 내 솔루션은 John Knoeller 가 그의 답변에서 제안한 것과 똑같 습니다. 로그 정보를 TextBox 또는 RichTextBox 컨트롤에 직접 저장하지 말고 대신 컨트롤 채우 거나 파일에 쓰는 데 사용할 수있는 로깅 클래스를 만듭니다 .

이 예제 솔루션에는 몇 가지 부분이 있습니다.

  1. 로깅 클래스 자체, Logger.
  2. 업데이트 후 아래로 스크롤 기능을 추가하도록 RichTextBox 컨트롤 수정 ScrollingRichTextBox.
  3. 사용을 보여주는 기본 양식, LoggerExample.

먼저 로깅 클래스 :

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;

namespace Logger
{
    /// <summary>
    /// A circular buffer style logging class which stores N items for display in a Rich Text Box.
    /// </summary>
    public class Logger
    {
        private readonly Queue<LogEntry> _log;
        private uint _entryNumber;
        private readonly uint _maxEntries;
        private readonly object _logLock = new object();
        private readonly Color _defaultColor = Color.White;

        private class LogEntry
        {
            public uint EntryId;
            public DateTime EntryTimeStamp;
            public string EntryText;
            public Color EntryColor;
        }

        private struct ColorTableItem
        {
            public uint Index;
            public string RichColor;
        }

        /// <summary>
        /// Create an instance of the Logger class which stores <paramref name="maximumEntries"/> log entries.
        /// </summary>
        public Logger(uint maximumEntries)
        {
            _log = new Queue<LogEntry>();
            _maxEntries = maximumEntries;
        }

        /// <summary>
        /// Retrieve the contents of the log as rich text, suitable for populating a <see cref="System.Windows.Forms.RichTextBox.Rtf"/> property.
        /// </summary>
        /// <param name="includeEntryNumbers">Option to prepend line numbers to each entry.</param>
        public string GetLogAsRichText(bool includeEntryNumbers)
        {
            lock (_logLock)
            {
                var sb = new StringBuilder();

                var uniqueColors = BuildRichTextColorTable();
                sb.AppendLine($@"{{\rtf1{{\colortbl;{ string.Join("", uniqueColors.Select(d => d.Value.RichColor)) }}}");

                foreach (var entry in _log)
                {
                    if (includeEntryNumbers)
                        sb.Append($"\\cf1 { entry.EntryId }. ");

                    sb.Append($"\\cf1 { entry.EntryTimeStamp.ToShortDateString() } { entry.EntryTimeStamp.ToShortTimeString() }: ");

                    var richColor = $"\\cf{ uniqueColors[entry.EntryColor].Index + 1 }";
                    sb.Append($"{ richColor } { entry.EntryText }\\par").AppendLine();
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry.
        /// </summary>
        public void AddToLog(string text)
        {
            AddToLog(text, _defaultColor);
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry, and specifies a color to display it in.
        /// </summary>
        public void AddToLog(string text, Color entryColor)
        {
            lock (_log)
            {
                if (_entryNumber >= uint.MaxValue)
                    _entryNumber = 0;
                _entryNumber++;
                var logEntry = new LogEntry { EntryId = _entryNumber, EntryTimeStamp = DateTime.Now, EntryText = text, EntryColor = entryColor };
                _log.Enqueue(logEntry);

                while (_log.Count > _maxEntries)
                    _log.Dequeue();
            }
        }

        /// <summary>
        /// Clears the entire log.
        /// </summary>
        public void Clear()
        {
            lock (_logLock)
            {
                _log.Clear();
            }
        }

        private Dictionary<Color, ColorTableItem> BuildRichTextColorTable()
        {
            var uniqueColors = new Dictionary<Color, ColorTableItem>();
            var index = 0u;

            uniqueColors.Add(_defaultColor, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(_defaultColor) });

            foreach (var c in _log.Select(l => l.EntryColor).Distinct().Where(c => c != _defaultColor))
                uniqueColors.Add(c, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(c) });

            return uniqueColors;
        }

        private string ColorToRichColorString(Color c)
        {
            return $"\\red{c.R}\\green{c.G}\\blue{c.B};";
        }
    }
}

Logger 클래스는 LogEntry줄 번호, 타임 스탬프 및 원하는 색상을 추적하는 다른 클래스 통합 합니다. 구조체는 서식있는 텍스트 색상 표를 만드는 데 사용됩니다.

다음은 수정 된 RichTextBox입니다.

using System;
using System.Runtime.InteropServices;

namespace Logger
{
    public class ScrollingRichTextBox : System.Windows.Forms.RichTextBox
    {
        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        private static extern IntPtr SendMessage(
            IntPtr hWnd,
            uint Msg,
            IntPtr wParam,
            IntPtr LParam);

        private const int _WM_VSCROLL = 277;
        private const int _SB_BOTTOM = 7;

        /// <summary>
        /// Scrolls to the bottom of the RichTextBox.
        /// </summary>
        public void ScrollToBottom()
        {
            SendMessage(Handle, _WM_VSCROLL, new IntPtr(_SB_BOTTOM), new IntPtr(0));
        }
    }
}

내가 여기서하는 일은 RichTextBox를 상속하고 "scroll to bottom"메서드를 추가하는 것입니다. StackOverflow에서이 작업을 수행하는 방법에 대한 다양한 다른 질문이 있습니다.

마지막으로, 양식에서이 클래스를 사용하는 예 :

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Logger
{
    public partial class LoggerExample : Form
    {
        private Logger _log = new Logger(100u);
        private List<Color> _randomColors = new List<Color> { Color.Red, Color.SkyBlue, Color.Green };
        private Random _r = new Random((int)DateTime.Now.Ticks);

        public LoggerExample()
        {
            InitializeComponent();
        }

        private void timerGenerateText_Tick(object sender, EventArgs e)
        {
            if (_r.Next(10) > 5)
                _log.AddToLog("Some event to log.", _randomColors[_r.Next(3)]);
        }

        private void timeUpdateLogWindow_Tick(object sender, EventArgs e)
        {
            richTextBox1.Rtf = _log.GetLogAsRichText(true);
            richTextBox1.ScrollToBottom();
        }
    }
}

This form is created with two timers, one to generate log entries pseudo-randomly, and one to populate the RichTextBox itself. In this example, the log class is instantiated with 100 lines of scroll-back. The RichTextBox control colors are set to have a black background with white and various color foregrounds. The timer to generate text is at a 100ms interval while the one to update the log window is at 1000ms.

Sample output:

Logger Example Output

It is far from perfect or finished, but here are some caveats and things that could be added or improved (some of which I have done in later projects):

  1. With large values for maximumEntries, performance is poor. This logging class was only designed for a few hundred lines of scroll-back.
  2. Replacing the text of the RichTextBox can result in flickering. I always keep the refresh timer at a relatively slow interval. (One second in this example.)
  3. Adding to #2 above, some of my projects check if the log has any new entries before redrawing the RichTextBox content, to avoid unnecessarily refreshing it.
  4. The timestamp on each log entry can be made optional and allow different formats.
  5. There is no way to pause the log in this example, but many of my projects do provide a mechanism for pausing the scrolling behavior, to allow users to manually scroll, select, and copy text from the log window.

Feel free to modify and improve upon this example. Feedback is welcome.

ReferenceURL : https://stackoverflow.com/questions/2196097/elegant-log-window-in-winforms-c-sharp

반응형