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 filter spurious 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 I was using the TextChange 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 now I 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. Any formatting done so far, may now be invalid. The formatting must now 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 during formatting, another keypress event occurs, 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 it's 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 begins to actually do its formatting work, only 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 while typing is occurring, no formatting begins. 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 interupted when applying these batches of changes.
The upshot is, the doc gets auto-formatted and colorized in discrete chunks, only 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 process is aborted, and the formatting starts all over again.
Whew!
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.