0

Background

I have a RichTextBox control I am using essentially like a console in my WinForms application. Currently my application-wide logger posts messages using delegates and one of the listeners is this RTB. The logger synchronously sends lots of short (less than 100 char) strings denoting event calls, status messages, operation results, etc.

Posting lots of these short messages to the RTB using BeginInvoke provides UI responsiveness until heavy parallel processing starts logging lots of messages and then the UI starts posting items out of order, or the text is far behind (hundreds of milliseconds). I know this because when the processing slows down or is stopped, the console keeps writing for some time afterwords.

My temporary solution was to invoke the UI synchronously and add a blocking collection buffer. Basically taking the many small items from the Logger and combining them in a stringbuilder to be posted in aggregate to the RTB. The buffer posts items as they come if the UI can keep up, but if the queue gets too high, then it aggregates them and then posts to the UI. The RTB is thus updated piece-meal and looks jumpy when lots of things are being logged.

Question

How can I run a RichTextBox control on its own UI thread to keep other buttons on the same Form responsive during frequent but small append operations? From research, I think I need to run an STA thread and call Application.Run() on it to put the RTB on its own thread, but the examples I found lacked substantive code samples and there don't seem to be any tutorials (perhaps because what I want to do is ill advised?). Also I wasn't sure if there where any pitfalls for a single Control being on its own thread relative to the rest of the Form. (ie. Any issues closing the main form or will the STA thread for the RTB just die with the form closing? Any special disposing? etc.)

This should demonstrate the issue once you add 3 Buttons and a RichTextBox to the form. What I essentially want to accomplish is factoring away the BufferedConsumer by having the RTB on its own thread. Most of this code was hacked out verbatim from my main application, so yes, it is ugly.

    using System;
    using System.Collections.Concurrent;
    using System.Diagnostics;
    using System.Drawing;
    using System.Runtime.InteropServices;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Windows.Forms;

namespace WindowsFormsApplication1
{
    public partial class Form1 : Form
    {
        // Fields
        private int m_taskCounter;
        private static CancellationTokenSource m_tokenSource;
        private bool m_buffered = true;
        private static readonly object m_syncObject = new object();

        // Properties
        public IMessageConsole Application_Console { get; private set; }
        public BufferedConsumer<StringBuilder, string> Buffer { get; private set; }

        public Form1()
        {
            InitializeComponent();

            m_tokenSource = new CancellationTokenSource();
            Application_Console = new RichTextBox_To_IMessageConsole(richTextBox1);

            Buffer =
                new BufferedConsumer<StringBuilder, string>(
                  p_name: "Console Buffer",
                  p_appendBuffer: (sb, s) => sb.Append(s),
                  p_postBuffer: (sb) => Application_Console.Append(sb));

            button1.Text = "Start Producer";
            button2.Text = "Stop All";
            button3.Text = "Toggle Buffering";

            button1.Click += (o, e) => StartProducerTask();
            button2.Click += (o, e) => CancelAllProducers();
            button3.Click += (o, e) => ToggleBufferedConsumer();
        }

        public void StartProducerTask()
        {
            var Token = m_tokenSource.Token;
            Task
                .Factory.StartNew(() =>
                {
                    var ThreadID = Interlocked.Increment(ref m_taskCounter);
                    StringBuilder sb = new StringBuilder();

                    var Count = 0;
                    while (!Token.IsCancellationRequested)
                    {
                        Count++;
                        sb.Clear();
                        sb
                            .Append("ThreadID = ")
                            .Append(ThreadID.ToString("000"))
                            .Append(", Count = ")
                            .AppendLine(Count.ToString());

                        if (m_buffered)
                            Buffer
                                .AppendCollection(sb.ToString()); // ToString mimicks real world Logger passing strings and not stringbuilders
                        else
                            Application_Console.Append(sb);

                        Sleep.For(1000);
                    }
                }, Token);
        }
        public static void CancelAllProducers()
        {
            lock (m_syncObject)
            {
                m_tokenSource.Cancel();
                m_tokenSource = new CancellationTokenSource();
            }
        }
        public void ToggleBufferedConsumer()
        {
            m_buffered = !m_buffered;
        }
    }

    public interface IMessageConsole
    {
        // Methods
        void Append(StringBuilder p_message);
    }

    // http://stackoverflow.com/a/5706085/1718702
    public class RichTextBox_To_IMessageConsole : IMessageConsole
    {
        // Constants
        private const int WM_USER = 0x400;
        private const int WM_SETREDRAW = 0x000B;
        private const int EM_GETEVENTMASK = WM_USER + 59;
        private const int EM_SETEVENTMASK = WM_USER + 69;
        private const int EM_GETSCROLLPOS = WM_USER + 221;
        private const int EM_SETSCROLLPOS = WM_USER + 222;

        //Imports
        [DllImport("user32.dll")]
        private static extern IntPtr SendMessage(IntPtr hWnd, Int32 wMsg, Int32 wParam, ref Point lParam);

        [DllImport("user32.dll")]
        private static extern IntPtr SendMessage(IntPtr hWnd, Int32 wMsg, Int32 wParam, IntPtr lParam);

        // Fields
        private RichTextBox m_richTextBox;
        private bool m_attachToBottom;
        private Point m_scrollPoint;
        private bool m_painting;
        private IntPtr m_eventMask;
        private int m_suspendIndex = 0;
        private int m_suspendLength = 0;

        public RichTextBox_To_IMessageConsole(RichTextBox p_richTextBox)
        {
            m_richTextBox = p_richTextBox;
            var h = m_richTextBox.Handle;

            m_painting = true;

            m_richTextBox.DoubleClick += RichTextBox_DoubleClick;
            m_richTextBox.MouseWheel += RichTextBox_MouseWheel;
        }

        // Methods
        public void SuspendPainting()
        {
            if (m_painting)
            {
                m_suspendIndex = m_richTextBox.SelectionStart;
                m_suspendLength = m_richTextBox.SelectionLength;
                SendMessage(m_richTextBox.Handle, EM_GETSCROLLPOS, 0, ref m_scrollPoint);
                SendMessage(m_richTextBox.Handle, WM_SETREDRAW, 0, IntPtr.Zero);
                m_eventMask = SendMessage(m_richTextBox.Handle, EM_GETEVENTMASK, 0, IntPtr.Zero);
                m_painting = false;
            }
        }
        public void ResumePainting()
        {
            if (!m_painting)
            {
                m_richTextBox.Select(m_suspendIndex, m_suspendLength);
                SendMessage(m_richTextBox.Handle, EM_SETSCROLLPOS, 0, ref m_scrollPoint);
                SendMessage(m_richTextBox.Handle, EM_SETEVENTMASK, 0, m_eventMask);
                SendMessage(m_richTextBox.Handle, WM_SETREDRAW, 1, IntPtr.Zero);
                m_painting = true;
                m_richTextBox.Invalidate();
            }
        }
        public void Append(StringBuilder p_message)
        {
            var WatchDogTimer = Stopwatch.StartNew();
            var MinimumRefreshRate = 2000;

            m_richTextBox
                .Invoke((Action)delegate
                {
                    // Last resort cleanup
                    if (WatchDogTimer.ElapsedMilliseconds > MinimumRefreshRate)
                    {
                        // m_richTextBox.Clear(); // Real-world behaviour

                        // Sample App behaviour
                        Form1.CancelAllProducers();
                    }

                    // Stop Drawing to prevent flickering during append and
                    // allow Double-Click events to register properly
                    this.SuspendPainting();
                    m_richTextBox.SelectionStart = m_richTextBox.TextLength;
                    m_richTextBox.SelectedText = p_message.ToString();

                    // Cap out Max Lines and cut back down to improve responsiveness
                    if (m_richTextBox.Lines.Length > 4000)
                    {
                        var NewSet = new string[1000];
                        Array.Copy(m_richTextBox.Lines, 1000, NewSet, 0, 1000);
                        m_richTextBox.Lines = NewSet;
                        m_richTextBox.SelectionStart = m_richTextBox.TextLength;
                        m_richTextBox.SelectedText = "\r\n";
                    }
                    this.ResumePainting();

                    // AutoScroll down to display newest text
                    if (m_attachToBottom)
                    {
                        m_richTextBox.SelectionStart = m_richTextBox.Text.Length;
                        m_richTextBox.ScrollToCaret();
                    }
                });
        }

        // Event Handler
        void RichTextBox_DoubleClick(object sender, EventArgs e)
        {
            // Toggle
            m_attachToBottom = !m_attachToBottom;

            // Scroll to Bottom
            if (m_attachToBottom)
            {
                m_richTextBox.SelectionStart = m_richTextBox.Text.Length;
                m_richTextBox.ScrollToCaret();
            }
        }
        void RichTextBox_MouseWheel(object sender, MouseEventArgs e)
        {
            m_attachToBottom = false;
        }
    }

    public class BufferedConsumer<TBuffer, TItem> : IDisposable
        where TBuffer : new()
    {
        // Fields
        private bool m_disposed = false;
        private Task m_consumer;
        private string m_name;
        private CancellationTokenSource m_tokenSource;
        private AutoResetEvent m_flushSignal;
        private BlockingCollection<TItem> m_queue;

        // Constructor
        public BufferedConsumer(string p_name, Action<TBuffer, TItem> p_appendBuffer, Action<TBuffer> p_postBuffer)
        {
            m_name = p_name;
            m_queue = new BlockingCollection<TItem>();
            m_tokenSource = new CancellationTokenSource();
            var m_token = m_tokenSource.Token;
            m_flushSignal = new AutoResetEvent(false);

            m_token
                .Register(() => { m_flushSignal.Set(); });

            // Begin Consumer Task
            m_consumer = Task.Factory.StartNew(() =>
            {
                //Handler
                //    .LogExceptions(ErrorResponse.SupressRethrow, () =>
                //    {
                // Continuously consumes entries added to the collection, blocking-wait if empty until cancelled
                while (!m_token.IsCancellationRequested)
                {
                    // Block
                    m_flushSignal.WaitOne();

                    if (m_token.IsCancellationRequested && m_queue.Count == 0)
                        break;

                    // Consume all queued items
                    TBuffer PostBuffer = new TBuffer();

                    Console.WriteLine("Queue Count = " + m_queue.Count + ", Buffering...");
                    for (int i = 0; i < m_queue.Count; i++)
                    {
                        TItem Item;
                        m_queue.TryTake(out Item);
                        p_appendBuffer(PostBuffer, Item);
                    }

                    // Post Buffered Items
                    p_postBuffer(PostBuffer);

                    // Signal another Buffer loop if more items were Queued during post sequence
                    var QueueSize = m_queue.Count;
                    if (QueueSize > 0)
                    {
                        Console.WriteLine("Queue Count = " + QueueSize + ", Sleeping...");
                        m_flushSignal.Set();

                        if (QueueSize > 10 && QueueSize < 100)
                            Sleep.For(1000, m_token);      //Allow Queue to build, reducing posting overhead if requests are very frequent
                    }
                }
                //});
            }, m_token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
        }
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected virtual void Dispose(bool p_disposing)
        {
            if (!m_disposed)
            {
                m_disposed = true;
                if (p_disposing)
                {
                    // Release of Managed Resources
                    m_tokenSource.Cancel();
                    m_flushSignal.Set();
                    m_consumer.Wait();
                }
                // Release of Unmanaged Resources
            }
        }

        // Methods
        public void AppendCollection(TItem p_item)
        {
            m_queue.Add(p_item);
            m_flushSignal.Set();
        }
    }

    public static partial class Sleep
    {
        public static bool For(int p_milliseconds, CancellationToken p_cancelToken = default(CancellationToken))
        {
            //p_milliseconds
            //    .MustBeEqualOrAbove(0, "p_milliseconds");

            // Exit immediate if cancelled
            if (p_cancelToken != default(CancellationToken))
                if (p_cancelToken.IsCancellationRequested)
                    return true;

            var SleepTimer =
                new AutoResetEvent(false);

            // Cancellation Callback Action
            if (p_cancelToken != default(CancellationToken))
                p_cancelToken
                    .Register(() => SleepTimer.Set());

            // Block on SleepTimer
            var Canceled = SleepTimer.WaitOne(p_milliseconds);

            return Canceled;
        }
    }
}
Trojan
  • 2,256
  • 28
  • 40
HodlDwon
  • 1,131
  • 1
  • 13
  • 30
  • 1
    Please look at [my example](http://stackoverflow.com/a/16745054/643085) of a similar thing using current, relevant .Net Windows UI technologies. – Federico Berasategui Aug 16 '13 at 18:54
  • @HighCore Can Winforms and WPF be mixed? I know the advantages of WPF, but I am using a Presenter First pattern in Winforms (inheritted winforms from my predacessor, I chose the pattern though) so I'd have to learn MVVM, XAML, etc. at the same time as refactoring 12KCLOCs and implementing new customer features in a timely manner and maintaining deployed version compatibility... and while the pattern I use might translate to MVVM/WPF fine... I really have no idea at this time. Next app I start from scratch, WPF and your console solution for sure though, so thanks. – HodlDwon Aug 16 '13 at 19:16
  • 1
    yes, you can integrate WPF content into an existing winforms application (provided it's running in .Net 3.0 or higher) using the [ElementHost](http://msdn.microsoft.com/en-us/library/system.windows.forms.integration.elementhost.aspx). You don't need to change the existing architecture / infrastructure, so this could be an escalated, step by step upgrade, or just keep the Log Viewer in WPF and the rest in winforms. – Federico Berasategui Aug 16 '13 at 19:19
  • 1
    @HighCore I got your code to compile in WPF and run the sample and it looks very promising. Feel free to repost your link as an answer and I will mark it as accepted. – HodlDwon Aug 16 '13 at 20:03

1 Answers1

1

Posting answer as per the OP's request:

You can integrate my example of a Virtualized, High-Performance, Rich, highly customizable WPF log Viewer in your existing winforms application by using the ElementHost

enter image description here

Full source code in the link above

Community
  • 1
  • 1
Federico Berasategui
  • 43,562
  • 11
  • 100
  • 154