5
votes

I'm trying to implement the Application.Find command for the WPF richtextbox. Let's say I'm searching for "expert". Sounds easy enough. But due to the nature of wpf, if every other letter in "expert" is bolded, then the richtextbox contains e*x*p*e*r*t* and that means six runs exist. I have a starting textPointer. What I'm trying to figure out is how to get the ending textPointer so that I can create a TextRange that I can use to create the Selection.

In this example, the starting textpointer is in the first run, and the ending textpointer should be in the last run. Is there a simple way to generate a textpointer if you know the run and the offset within the run? I tried generating it using a offset from the first textpointer but that did not work because the offset was not within the first run.

As a relative newbie to the WPF richtextbox, this one has me stumped. I imagine that this problem has already been tackled and solved. I did find one partial solution but it only worked on a single run and does not address the multiple run situation.

2

2 Answers

13
votes

The idea is to find the offset of the first character (IndexOf) and then to find the TextPointer at this index (but by counting only text characters).

public TextRange FindTextInRange(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);
    TextRange result = new TextRange(start, GetTextPositionAtOffset(start, searchText.Length));

    return result;
}

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

            characterCount -= count;
        }

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

        position = nextContextPosition;
    }

    return position;
}

This is how to use the code:

TextRange searchRange = new TextRange(richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd);
TextRange foundRange = FindTextInRange(searchRange, "expert");
foundRange.ApplyPropertyValue(TextElement.ForegroundProperty, new SolidColorBrush(Colors.Red));
0
votes

After testing meziantou's solution and getting misplaced textrange's with my test data, I came up with this by traversing the text range on a per character basis. I scoured the internet and meziantou's sample is the ONLY thing out there I came across that even considers multiple runs (which should be pretty common you would think right?) but it does not work with new lines or uielements. My solution below DOES, see comments for more details and where to handle inline elements:

public static List<TextRange> FindStringRangesFromPosition(TextPointer position, string matchStr, bool isCaseSensitive = false) {
        var matchRangeList = new List<TextRange>();
        while (position != null) {
            var hlr = FindStringRangeFromPosition(position, matchStr, isCaseSensitive);
            if (hlr == null) {
                break;
            } else {
                matchRangeList.Add(hlr);
                position = hlr.End;
            }
        }
        return matchRangeList;
    }

    public static TextRange FindStringRangeFromPosition(TextPointer position, string matchStr, bool isCaseSensitive = false) {
        int curIdx = 0;
        TextPointer startPointer = null;
        StringComparison stringComparison = isCaseSensitive ? StringComparison.CurrentCulture : StringComparison.CurrentCultureIgnoreCase;
        while (position != null) {   
            if (position.GetPointerContext(LogicalDirection.Forward) != TextPointerContext.Text) {
                if(position.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.EmbeddedElement) {
                    var inlineUIelement = position.Parent;
                    //handle inlineUIelement.Child contents here...
                }
                position = position.GetNextContextPosition(LogicalDirection.Forward);
                continue;
            }
            var runStr = position.GetTextInRun(LogicalDirection.Forward);
            if (string.IsNullOrEmpty(runStr)) {
                position = position.GetNextContextPosition(LogicalDirection.Forward);
                continue;
            }
            //only concerned with current character of match string
            int runIdx = runStr.IndexOf(matchStr[curIdx].ToString(), stringComparison);
            if (runIdx == -1) {
                //if no match found reset search
                curIdx = 0;
                if (startPointer == null) {
                    position = position.GetNextContextPosition(LogicalDirection.Forward);
                } else {
                    //when no match somewhere after first character reset search to the position AFTER beginning of last partial match
                    position = startPointer.GetPositionAtOffset(1, LogicalDirection.Forward);
                    startPointer = null;
                }
                continue;
            }
            if (curIdx == 0) {
                //beginning of range found at runIdx
                startPointer = position.GetPositionAtOffset(runIdx, LogicalDirection.Forward);
            }
            if (curIdx == matchStr.Length - 1) {
                //each character has been matched
                var endPointer = position.GetPositionAtOffset(runIdx, LogicalDirection.Forward);
                //for edge cases of repeating characters these loops ensure start is not early and last character isn't lost 
                if (isCaseSensitive) {
                    while (endPointer != null && !new TextRange(startPointer, endPointer).Text.Contains(matchStr)) {
                        endPointer = endPointer.GetPositionAtOffset(1, LogicalDirection.Forward);
                    }
                } else {
                    while (endPointer != null && !new TextRange(startPointer, endPointer).Text.ToLower().Contains(matchStr.ToLower())) {
                        endPointer = endPointer.GetPositionAtOffset(1, LogicalDirection.Forward);
                    }
                }
                if (endPointer == null) {
                    return null;
                }
                while (startPointer != null && new TextRange(startPointer, endPointer).Text.Length > matchStr.Length) {
                    startPointer = startPointer.GetPositionAtOffset(1, LogicalDirection.Forward);
                }
                if (startPointer == null) {
                    return null;
                }
                return new TextRange(startPointer, endPointer);
            } else {
                //prepare loop for next match character
                curIdx++;
                //iterate position one offset AFTER match offset
                position = position.GetPositionAtOffset(runIdx + 1, LogicalDirection.Forward);
            }
        }
        return null;
    }

Usage:

List<TextRange> matchRangeList = FindStringRangesFromPosition(rtb.Document.ContentStart,"expert",false);