Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

WinForms RichTextBox : how to reformat asynchronously, without firing TextChanged event

This is a followup to
WinForms RichTextBox: how to perform a formatting on TextChanged?

I have a Winforms app with a RichTextBox, the app auto-highlights the content of said box. Because the formatting can take a long time for a large document, 10 seconds or more, I've set up a BackgroundWorker to do the re-formatting of a RichTextBox. It walks through the text and performs a series of these:

rtb.Select(start, length);
rtb.SelectionColor = color;

While it is doing this, the UI remains responsive.

The BackgroundWorker is kicked off from the TextChanged event. like this:

private ManualResetEvent wantFormat = new ManualResetEvent(false);
private void richTextBox1_TextChanged(object sender, EventArgs e)
{
    xpathDoc = null;
    nav = null;
    _lastChangeInText = System.DateTime.Now;
    if (this.richTextBox1.Text.Length == 0) return;
    wantFormat.Set();
}

The background worker method looks like this:

private void DoBackgroundColorizing(object sender, DoWorkEventArgs e)
{
    do
    {
        wantFormat.WaitOne();
        wantFormat.Reset();

        while (moreToRead())
        {
            rtb.Invoke(new Action<int,int,Color>(this.SetTextColor,
                      new object[] { start, length, color} ) ;
        }                

    } while (true);
}

private void SetTextColor(int start, int length, System.Drawing.Color color)
{
   rtb.Select(start, length);
   rtb.SelectionColor= color;
}

But, every assignment to SelectionColor causes the TextChanged event to fire: An endless loop.

How do I distinguish between text changes that originate externally from text changes that originate from the BackgroundWorker doing the formatting?

I could also solve this if I could detect a text content change independently of a text format change.

like image 987
Cheeso Avatar asked Sep 21 '09 23:09

Cheeso


2 Answers

The approach I took was to run the formatter logic in a BackgroundWorker. I chose this because the format would take a "long" time, more than 1 second or two, so I couldn't do it on the UI thread.

Just to restate the problem: every call made by the BackgroundWorker to the setter on RichTextBox.SelectionColor fired the TextChanged event again, which would start the BG thread all over again. Within the TextChanged event, I could find no way to distinguish a "user has typed something" event from a "program has formatted the text" event. So you can see it would be an infinite progression of changes.

The Simple Approach Does Not Work

A common approach (as suggested by Eric) is to "disable" text change event handling while running within the text change handler. But of course this won't work for my case, because the text changes (SelectionColor changes) are being generated by a background thread. They are not being performed within the scope of a text change handler. So the simple approach to filtering user-initiated events will not work for my case, where a background thread is making changes.

Other attempts to Detect user-initiated changes

I tried using the RichTextBox.Text.Length as a way to distinguish the changes in the richtextbox originating from my formatter thread from the changes in the richtextbox made by the user. If the Length had not changed, I reasoned, then the change was a format change done by my code, and not a user edit. But retrieving the RichTextBox.Text property is expensive, and doing that for every TextChange event made the entire UI unacceptably slow. Even if this was fast enough, it doesn't work in the general case, because users make format changes, too. And, a user edit might produce the same length text, if it was a typeover sort of operation.

I was hoping to catch and handle the TextChange event ONLY to detect changes originating from the user. Since I couldn't do that, I changed the app to use the KeyPress event and the Paste event. As a result I now don't get spurious TextChange events due to formatting changes (like RichTextBox.SelectionColor = Color.Blue).

Signalling the worker thread to do its Work

OK, I've got a thread running that can do formatting changes. Conceptually, it does this:

while (forever)
    wait for the signal to start formatting
    for each line in the richtextbox 
        format it
    next
next

How can I tell the BG thread to start formatting?

I used a ManualResetEvent. When a KeyPress is detected, the keypress handler sets that event (turns it ON). The background worker is waiting on the same event. When it is turned on, the BG thread turns it off, and begins formatting.

But what if the BG worker is already formatting? In that case, a new keypress may have changed the content of the textbox, and any formatting done so far may now be invalid, so the formatting must be restarted. What I really want for the formatter thread is something like this:

while (forever)
    wait for the signal to start formatting
    for each line in the richtextbox 
        format it
        check if we should stop and restart formatting
    next
next

With this logic, when the ManualResetEvent is set (turned on), the formatter thread detects that, and resets it (Turns it off), and begins formatting. It walks through the text and decides how to format it. Periodically the formatter thread checks the ManualResetEvent again. If another keypress event occurs during formatting, then the event again goes to a signalled state. When the formatter sees that it's re-signalled, the formatter bails out and starts formatting again from the beginning of the text, like Sisyphus. A more intelligent mechanism would restart formatting from the point in the document where the change occurred.

Delayed Onset Formatting

Another twist: I don't want the formatter to begin its formatting work immediately with every KeyPress. As a human types, the normal pause between keystrokes is less than 600-700ms. If the formatter starts formatting without a delay, then it will try to begin formatting between keystrokes. Pretty pointless.

So the formatter logic only begins to do its formatting work if it detects a pause in keystrokes of longer than 600ms. After receiving the signal, it waits 600ms, and if there have been no intervening keypresses, then the typing has stopped and the formatting should start. If there has been an intervening change, then the formatter does nothing, concluding that the user is still typing. In code:

private System.Threading.ManualResetEvent wantFormat = new System.Threading.ManualResetEvent(false);

The keypress event:

private void richTextBox1_KeyPress(object sender, KeyPressEventArgs e)
{
    _lastRtbKeyPress = System.DateTime.Now;
    wantFormat.Set();
}

In the colorizer method, which runs in the background thread:

....
do
{
    try
    {
        wantFormat.WaitOne();
        wantFormat.Reset();

        // We want a re-format, but let's make sure 
        // the user is no longer typing...
        if (_lastRtbKeyPress != _originDateTime)
        {
            System.Threading.Thread.Sleep(DELAY_IN_MILLISECONDS);
            System.DateTime now = System.DateTime.Now;
            var _delta = now - _lastRtbKeyPress;
            if (_delta < new System.TimeSpan(0, 0, 0, 0, DELAY_IN_MILLISECONDS))
                continue;
        }

        ...analyze document and apply updates...

        // during analysis, periodically check for new keypress events:
        if (wantFormat.WaitOne(0, false))
            break;

The user experience is that no formatting occurs while they are typing. Once typing pauses, formatting starts. If typing begins again, the formatting stops and waits again.

Disabling Scrolling during format changes

There was one final problem: formatting the text in a RichTextBox requires a call to RichTextBox.Select(), which causes the RichTextBox to automatically scroll to the text selected, when the RichTextBox has focus. Because the formatting is happening at the same time the user is focused in the control, reading and maybe editing the text, I needed a way to suppress the scrolling. I could not find a way to prevent scrolling using the public interface of RTB, although I did find many people in the intertubes asking about it. After some experimenting, I found that using the Win32 SendMessage() call (from user32.dll), sending WM_SETREDRAW before and after the Select(), can prevent the scroll in the RichTextBox when calling Select().

Because I was resorting to pinvoke to prevent the scrolling, I also used pinvoke on SendMessage to get or set the selection or caret in the textbox (EM_GETSEL or EM_SETSEL), and to set the formatting on the selection (EM_SETCHARFORMAT). The pinvoke approach ended up being slightly faster than using the managed interface.

Batch Updates for responsiveness

And because preventing scrolling incurred some compute overhead, I decided to batch up the changes made to the document. Instead of highlighting one contiguous section or word, the logic keeps a list of highlight or format changes to make. Every so often, it applies maybe 30 changes at a time to the document. Then it clears the list and goes back to analyzing and queuing which format changes need to be made. It's fast enough that typing in the doc is not interrupted when applying these batches of changes.

The upshot is the document gets auto-formatted and colorized in discrete chunks, when no typing is happening. If enough time passes between user keypresses, the entire document will eventually get formatted. This is under 200ms for a 1k XML doc, maybe 2s for a 30k doc, or 10s for a 100k doc. If the user edits the document, then any formatting that was in progress is aborted, and the formatting starts all over again.


Phew!

I am amazed that something as seemingly simple as formatting a richtextbox while the user types in it is so involved. But I couldn't come up with anything simpler that did not lock the text box, yet avoided the weird scrolling behavior.


You can view the code for the thing I described above.

like image 135
10 revs, 2 users 90% Avatar answered Sep 28 '22 07:09

10 revs, 2 users 90%


Typically when I react in an event handler in a manner that might cause the same event to be fired again, I set a flag indicating I'm already processing the event handler, check the flag at the top of the event handler, and immediately return if the flag is set:

bool processing = false;

TextChanged(EventArgs e)
{
    if (processing) return;

    try
    {
        processing = true;
        // You probably need to lock the control here briefly in case the user makes a change
        // Do your processing
    }
    finally
    {
        processing = false;
    }
}

If it's unacceptable to lock the control while performing your processing, you could check for the KeyDown event on your control and clear the processing flag when you receive it (probably also terminate your current TextChanged processing if it's potentially lengthy).

EDIT:

Complete, working code

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Forms;
using System.ComponentModel;

namespace BgWorkerDemo
{
    public class FormatRichTextBox : RichTextBox
    {
        private bool processing = false;

        private BackgroundWorker worker = new BackgroundWorker();

        public FormatRichTextBox()
        {
            worker.DoWork += new DoWorkEventHandler(worker_DoWork);
        }

        delegate void SetTextCallback(string text);
        private void SetText(string text)
        {
            Text = text;
        }

        delegate string GetTextCallback();
        private string GetText()
        {
            return Text;
        }

        void worker_DoWork(object sender, DoWorkEventArgs e)
        {
            try
            {
                GetTextCallback gtc = new GetTextCallback(GetText);
                string text = (string)this.Invoke(gtc, null);

                StringBuilder sb = new StringBuilder();
                for (int i = 0; i < text.Length; i++)
                {
                    sb.Append(Char.ToUpper(text[i]));
                }

                SetTextCallback stc = new SetTextCallback(SetText);
                this.Invoke(stc, new object[]{ sb.ToString() });
            }
            finally
            {
                processing = false;
            }
        }

        protected override void OnTextChanged(EventArgs e)
        {
            base.OnTextChanged(e);

            if (processing) return;

            if (!worker.IsBusy)
            {
                processing = true;
                worker.RunWorkerAsync();
            }
        }

        protected override void OnKeyDown(KeyEventArgs e)
        {
            if (processing)
            {
                BeginInvoke(new MethodInvoker(delegate { this.OnKeyDown(e); }));
                return;
            }

            base.OnKeyDown(e);
        }

    }
}
like image 22
Eric J. Avatar answered Sep 28 '22 05:09

Eric J.