NSTextFinder Magic

NSTextFinder is a new Cocoa class added in OS X 10.7. It is basically a container for a find bar to be used with a NSScrollView and lets the user search (and optionally replace) text inside the NSScrollView associated with it. NSTextFinder is more or less identical to the search and find interface used by Safari or TextEdit, including incremental search, window dimming, results highlighting, pattern search and so on. It seems that it was contained as a private class already in earlier OS X versions.

Apple’s documentation on NSTextFinder is a bit sparse and it is not obvious how to set up the find bar. It took me a couple of days to figure everything important out so I decided to write it down and to describe it in this article, just that I don’t forget and that it may be useful for you. Disclaimer: I’ve not tested everything to the end, the code is not really beautiful and there’s some boiler plate code inside the article, so be careful when re-using it. I also haven’t tried to implement replacing text, so this article covers only searching text with NSTextFinder, and I’m using a NSTableView inside the scroll view, not a text view.

The Basics

Apple’s official documentation for NSTextFinder consists of the NSTextFinder class reference, the NSTextFinderClient protocol reference and the NSTextFinderBarContainer protocol reference. These documents describe NSTextFinder as a controller class for the actual find bar, looking like this:

NSTextFinder implementation

NSTextFinder is interacting with a kind of delegate called client. The client has to provide information on the text to be searched by the find bar, i.e. the text contained in the NScrollView associated with the NSTextFinder instance, and has to follow the NSTextFinderClient protocol. The find bar is embedded in a NSScrollView that has to follow the NSTextFinderBarContainer protocol, which it actually does. Besides this documentation there is nothing much at the web, especially there are no tutorials and there is no sample code.

The basic structure of how NSTextFinder interacts with an application can be illustrated like this:

NSTextFinder Diagram

NSTextFinder controls the find bar and does (almost) all interaction with the NSScrollView, especially regarding displaying the find bar and resizing the scroll view. The NSTextFinder client is responsible for giving NSTextFinder all information on the content of the NSScrollView, that is (in our example) the text inside the rows and columns of the NSTableView contained in the scroll view. The client does this by accessing the application’s data model. Almost all of the code we need to implement NSTextFinder will be inside the client.

The client can be designed as a separate custom controller object (in a NSObject subclass), but it may also be part of the AppDelegate. However, from the overall design of NSTextFinder it seems that the (subclassed) NSScrollView instance is meant to act as client. This is not really visible from NSTextFinder’s documentation but there are some hints in the documentation of NSResponder’s new 10.7 performTextFinderAction: method:

When an application performs a find action, it should send this message to the responder chain. A responder of performTextFinderAction: is responsible for creating and owning an instance of NSTextFinder.

The Setup

Setting up a NSTextFinder implementation begins with some decisions on where to implement the client code. I have chosen to have my own controller class for this which is initiated and called by the AppDelegate. Besides the code to initialize the client controller, the AppDelegate needs to have an action methods connected to the Find command in the application’s main menu (this should be done in Interface Builder). The code is like this:

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
    // ..
    textFinderController = [[MyTextFinderController alloc] init];
    // ..
}

-(IBAction)performFindPanelAction:(id)sender // Called when the find command is invoked by the user
{
    [textFinderController setScrollView:myScrollView]; // Tell the controller about the scroll view
    [textFinderController setContentController:myContentController]; // Tell the controller about the model
    [textFinderController performAction:sender]; // Tell the controller to show the find bar
}

The NSTextFinder client needs to know about the scroll view in which the find bar is shown, and it needs to be able to access the text contained in the table view. Most likely the table view will be controlled by a NSArrayController instance (in the code sample this is myContentController), so a reference to this controller is passed to the client controller.

Additionally, it is possible to decide if the find bar should be displayed at the top or at the bottom end of the scroll view, there is a setting for this in Interface Builder.

Constructing the Client: Indexing

Though NSTextFinder does all the actual text searching for us (we’ll come to this in a minute), it needs to know about the text to be searched. When using NSTextFinder with a NSScrollView, we obviously don’t have one corpus of all the text but quite a number of separate strings in all the table cells. NSTextFinder expects to either get one big string object containing all this text (this is not really feasible, nor practical) or to get at least substrings of the (hypothetical) overall string. NSTextFinder uses a delegate method to ask for these substrings and our client controller has to be able to deliver it.

This can be done by indexing the text that is contained in all table cells. This index operation should be done when the find bar is just showing up and the table view’s content shouldn’t be allowed to change after the index has been constructed. I use a NSMutableArray instance variable in the client controller to store the index, which contains of NSDictionary objects. My table view has only one column and each cell consists of two strings, a title string and a summary string. The dictionaries consist of three properties (all in NSNumber containers):

  • An NSInteger property containing the row number in the table view corresponding to the dictionary object. I use this to access the table cells’ content by using the [myContentController arrangedObjects] property (with some fancy custom accessor methods).
  • An NSInteger property containing an index value. This index is the running position of the first character of the substring represented by the dictionary object in the hypothetical big string comprising all the content of the table view.
  • A bool property on whether the dictionary object contains index data on the title or on the summary of a table row. This is necessary as the row number will be the same for both.

The index is calculated only once while the find bar is visible, my indexing code looks like this:

-(NSInteger)calculateIndex
{
    [finderIndex removeAllObjects]; // Clear earlier index
    int i;
    NSInteger count = 0; // Count is a running counter to get the index
    for (i=0;i 0) {
            idx = [NSNumber numberWithInteger:count]; // Running index
            row = [NSNumber numberWithInt:i]; // Row number
            type = [NSNumber numberWithBool:YES]; // Dictionary is representing the row's title
            NSDictionary *dict = [NSDictionary dictionaryWithObjectsAndKeys:idx, indexKey, row, rowKey, type, typeKey, nil];
            [finderIndex addObject:dict]; // add object to index
            count += len; // increase our running index
        }

        // Calculate index of row's summary
        len = [[myContentController summaryAtRow:i] length];
        if (len > 0) {
            idx = [NSNumber numberWithInteger:count]; // Running index
            row = [NSNumber numberWithInt:i]; // Row number
            type = [NSNumber numberWithBool:NO]; // Dictionary is representing the row's summary
            NSDictionary *dict = [NSDictionary dictionaryWithObjectsAndKeys:idx, indexKey, row, rowKey, type, typeKey, nil];
            [finderIndex addObject:dict];
            count += len; // increase our running index
        }
    }
    return count; // This is the overall length of all substrings of the table view taken together
}

After the index has been calculated, we can retrieve the dictionary object for a specific location inside the hypothetical overall string by using a method like this:

-(NSDictionary*)dictForLocation:(NSInteger)location
{
    NSDictionary *result;
    NSInteger cnt = [finderIndex count];
    NSInteger pos = cnt;
    NSInteger currentIdx;
    do {
        pos--;
        currentIdx = [[[finderIndex objectAtIndex:pos] valueForKey:indexKey] integerValue];
    } while ((pos > 0) && (currentIdx > location));
    result = [finderIndex objectAtIndex:pos];
    return result;
}

Constructing the Client: Delegate Methods

After the find bar has been displayed on the screen and the user starts searching for text, NSTextFinder will send several delegate methods to the client controller. The delegate methods won’t be called in a specific order, but I observed that at most times the calls followed this pattern:

-(NSUInteger) stringLength – NSTextFinder expects the client controller to calculate the overall length of the (in our case hypothetical) big string comprising all the text inside the scroll view. We can use the calculateIndex method presented above which will have this value as its return value. I also store the length in an instance variable so that I don’t have to call the calculateIndex method more than once.

-(NSString ) stringAtIndex: (NSUInteger) characterIndex effectiveRange: (NSRangePointer) outRange endsWithSearchBoundary: (BOOL ) outFlag – This method is called to get a small substring starting at the location marked by characterIndex. It will be called multiple times while NSTextFinder is searching the text for the search string entered by the user. We can retrieve the substring wanted by questioning our index using code like this:

- (NSString *)stringAtIndex:(NSUInteger)characterIndex effectiveRange:(NSRangePointer)outRange endsWithSearchBoundary:(BOOL *)outFlag
{
    NSDictionary *dict = [self dictForLocation:characterIndex];
    BOOL type = [[dict valueForKey:typeKey] boolValue];
    NSInteger row = [[dict valueForKey:rowKey] integerValue];
    NSString *str;
    if (type) {
        str = [myContentController titleAtRow:row];
    } else {
        str = [myContentController summaryAtRow:row];
    }
    (*outRange).location = [[dict valueForKey:indexKey] integerValue];
    (*outRange).length = [str length];
    *outFlag = YES;
    return (str);
}

Note that it is open to us how long the substring is we return. It is advisable to return something like the content of the table cell in which the string located at position characterIndex is situated. The method expects not only to receive the substring but we have also to set the outRange and outFlag variables.

-(void) scrollRangeToVisible: (NSRange) range – After NSTextFinder has ended searching, it will call this method and ask the client controller to scroll the scroll view to the position of the text indicated by range.location. We can compute the position by again questioning our index:

- (void)scrollRangeToVisible:(NSRange)range
{
    NSDictionary *dict = [self dictForLocation:range.location];
    NSInteger row = [[dict valueForKey:rowKey] integerValue];
    [[scrollView documentView] scrollRowToVisible:row];
}

-(NSView *) contentViewAtIndex: (NSUInteger) index effectiveCharacterRange: (NSRangePointer) outRange – Now NSTextFinder will ask for the view in which the substring with the index index is situated. We will have to return the view and in addition we again have to set the outrange variable to the effective length of the substring there. Code can look like this:

- (NSView *)contentViewAtIndex:(NSUInteger)index effectiveCharacterRange:(NSRangePointer)outRange
{
    NSDictionary *dict = [self dictForLocation:index];
    BOOL type = [[dict valueForKey:typeKey] boolValue];
    NSInteger row = [[dict valueForKey:rowKey] integerValue];
    NSString *str;
    NSInteger tag;
    if (type) {
        str = [feedController titleAtRow:row];
        tag = 1400; // This tag is set in our view prototype in Interface Builder
    } else {
        str = [feedController summaryAtRow:row];
        tag = 1401; // This tag is set in our view prototype in Interface Builder
    }
    (*outRange).location = [[dict valueForKey:indexKey] integerValue];
    (*outRange).length = [str length];

    NSTableCellView *cellView = [[scrollView documentView] viewAtColumn:0 row:row makeIfNecessary:YES];
    NSArray *subviews = [cellView subviews];
    NSTextField *textField;
    int i;
    for (i=0;i<[subviews count];i++) {
        if ([[subviews objectAtIndex:i] tag] == tag) textField = [subviews objectAtIndex:i];
    }
    return (textField);
}

Afterwards, NSTextFinder will call the rectsForCharacterRange: and the drawCharactersInRange:forContentView: methods. They ask for info on the position of the substring inside the scroll view, NSTextFinder uses this info to graphically highlight the substring. While it is quite easy to retrieve the rect of the substring, getting the right position for specific characters inside the substring can be tricky. Possible implementations will use the NSString Additions.

Constructing the Client: The Rest

There are plenty of more options to fiddle around with inside NSTextFinder. This regards incremental search, window dimming, selection and replacement of search results and so on. However, once the basic setup is done and search is working, it shouldn't be too difficult to configure this.

Implementation Tweaks

If we use a view-based NSTableView to present our content, it is necessary to subclass NSScrollView and to have an empty performTextFinderAction: method in it, even when you decide not to implement the client controller in a NSScrollView subclass:

-(void)performTextFinderAction:(id)sender
{
}

Otherwise the application will sooner or later freeze when using NSTextFinder with a view-based NSTableView. This is likely a Cocoa bug. The performTextFinderAction: method has been added to NSResponder in OS X 10.7. [Update: This bug has been fixed by Apple in OS X 10.8].