Thursday, August 20, 2015

Finding and highlighting a section of text in WPF RichTextBox

I'm working on a hobby project and I was stumped by this seemingly simple task. RichTextBox class does not implement a straightforward way to do this out of the box. I found several useful snippets of code on StackOverflow but none worked perfectly with formatted text I was displaying in my RichTextBox.

Text spread over multiple paragraphs, spans and containing embedded elements proved to be particularly problematic.  I figured out that I need to somehow use TextPointer class to find the section of the text I wanted to highlight. The main problem I had was finding the text within the document and then correctly obtaining the absolute character positions for the start of the section and the end of the section. If the document contained multiple paragraphs and the text I was trying to highlight spanned over multiple lines of text, the built-in methods would return character positions which were slightly off.

My final solution was to implement TextPointer class extensions which account and adjust for new lines and embedded elements to return position pointers which then allowed me to apply styling to desired sections of text.

You can use the TextPointer extensions like this:
   //NOTE: RtbEditor is the RichTextBox control I'm using to display text

   // Create a new TextRange that takes the entire FlowDocument as the current selection.
   var searchRange = new TextRange(RtbEditor.Document.ContentStart, RtbEditor.Document.ContentEnd);

   // Find the section of text
   var foundRange = searchRange.FindTextInRange("The brown fox jumped over the lazy dog");
   if (foundRange != null)
   {
    foundRange.ApplyPropertyValue(TextElement.BackgroundProperty, new SolidColorBrush(Colors.Yellow));
   }

And the TextPointerExtensions class you need to include in your project:
 public static class TextPointerExtensions
 {
  public static TextRange FindTextInRange(this TextRange searchRange, string searchText)
  {
   int offset = searchRange.Text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase);
   if (offset < 0)
    return null;  // Not found

   var start = GetTextPositionAtOffset(searchRange.Start, offset);
   var end = GetTextPositionAtOffset(start, searchText.Length);
   var result = new TextRange(start, end);

   return result;
  }

  private static TextPointer GetTextPositionAtOffset(this TextPointer position, int offset)
  {
   while (position != null)
   {
    if (position.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.Text)
    {
     int count = position.GetTextRunLength(LogicalDirection.Forward);
     if (offset <= count)
     {
      return position.GetPositionAtOffset(offset);
     }
     offset -= count;
    }

    var nextContextPosition = position.GetNextContextPosition(LogicalDirection.Forward);
    if (nextContextPosition == null)
     return position;

    position = nextContextPosition;
    switch (nextContextPosition.GetPointerContext(LogicalDirection.Forward))
    {
     case TextPointerContext.ElementEnd:
      //adjust for line breaks if current element is an end element and the next element is a paragraph
      if (nextContextPosition.GetAdjacentElement(LogicalDirection.Forward) is Paragraph)
      {
       offset -= Environment.NewLine.Length;
      }
      break;
     case TextPointerContext.EmbeddedElement:
      //TODO: may need to adjust offset for embedded elements too
      //needs further testing to ensure it works correctly in a variety of scenarios
      offset--;
      break;
     default:
      break;

    }
   }

   return position;
  }
 }

No comments:

Post a Comment