Publishers of technology books, eBooks, and videos for creative people

Home > Articles > Apple > Operating Systems

  • Print
  • + Share This
This chapter is from the book

Step 3: Implement the Add Tag Push Button

The Add Tag button works like the Add Entry button, with two exceptions. One exception is that the special character marking an entry's tag list is different from the character marking entry titles, to allow separate searches for titles and for tags. The other is that the tag list is inserted immediately following the title of whichever diary entry currently has keyboard focus in the split view; that is, the diary entry in which the text insertion cursor is currently located.

The tag list is actually a paragraph of text starting with the special character for tags followed by a space, the word Tags, a colon, another space, and tag words or phrases typed by the user. The Chef's Diary is not a database with records and fields. It is a simple RTF text object, and the tags can be edited like any other text. Special characters are used to identify the entry titles and the tags simply to make it easy to locate them programmatically. Entering the actual tags in the tag list is up to the user, who simply types them.

In Vermont Recipes, individual tags can be separated by any white space or punctuation marks, such as commas and spaces. This is done mostly for the sake of simplifying the code, but it works rather well. For example, if the tag list is ho hum, the user can use the search field to search for ho, hum, or ho hum. The problem with this is that the first two characters of hotel in a tag list will also be found. In a real-world application, you should use a more sophisticated approach. For now, just make a note of it.

A significant difference in the code for the new -addTag: action method is dictated by the need to find the title of the current diary entry so that the tag list can be inserted immediately following it, instead of at the end of the text view. A newline character is always inserted before the tag list because the tag list is always inserted at the end of the current title, before the title's trailing newline character. The tag list has no trailing newline character because the user should be allowed to start typing a new tag immediately following the Tags: label. If the entry already has a tag list, the method places the insertion pointer at its end, after the last tag, so that the user can immediately start typing additional tags.

Another difference in the -addTag: method is that it should look for the next diary entry, in order to define the end of the search range for an existing tag list in the current entry. This is necessary in part because the diary is a simple text file, not a database file, and the user might type additional text between a title and a tag list. Although the Add Tag button always inserts a new tag immediately following the title of the current entry, it should be user friendly and accommodate any user who has typed text between the current entry's title and an existing tag list. Another reason the search must stop at the next entry title marker is that tags are optional and the current entry may not have a tag list at all.

Looking ahead, you can see that the code to locate the title of the current and next entries might do double duty in the implementation of the navigation buttons, coming up shortly. It would therefore be a good idea to place this navigation code in separate methods. Call them -currentEntryTitleRangeForIndex: and -nextEntryTitleRangeForIndex:, and have them return both the location and the length of the title as an NSRange structure. Once you have written those two methods, you will be ready to write the method that returns the location and length of the current entry's tag list, if there is one, or the location where the tag list should be inserted and a length of 0 if there is no existing tag list in the current entry. You will call that method -currentEntryTagRangeForIndex:. Only after you have written these three supporting methods will you be ready to write the -addTag: action method.

As you work through the rest of this recipe, you may be struck by the fact that so many methods return an NSRange struct. The Cocoa text system relies heavily on ranges for efficiency and convenience. A text storage object can be very long, and you don't want to have to search the whole thing every time you need to find one substring or character in it.

Before getting started, consider again the MVC design pattern. The methods you are about to write start with information about the insertion point or the current selection in one of the diary window's text views, and they use that information to guide a search for characters in the diary document's text storage. They then add text to the text storage, and they end by changing the text view's selection and scrolling the new selection into view. These operations involve both the MVC model and the MVC view. You therefore implement some of them in the DiaryWindowController class and some of them in the DiaryDocument class. Searching for characters in the text storage object and adding tags to it is work for the MVC model, so you place these supporting methods in the DiaryDocument class, which, as you know, acts as a model controller.

Another thing to consider is code reuse. The methods you are about to write, and others like them that you will write later, involve some repetitive operations. You should look for every opportunity to gather repetitive code into separate methods for easy reuse.

Start with the DiaryDocument methods. I will reproduce one or two of these methods here. The rest are similar. Rather than reproduce all of them in the book, I refer you to the downloadable project file for Recipe 4 at www.peachpit.com/cocoarecipes, where they are laid out in full.

  1. Just as you wrote the -entryMarker, -entryTitleForDate:, and -entryTitleInsertForDate: methods in Step 2 for the entry title, you need to write -tagMarker, -tagTitle, and -tagTitleInsert methods for the tag title. They are much simpler, because they don't have to construct any date or time strings. You will find them in the downloadable project file for Recipe 4.

  2. Now you're ready to write the first of several methods that obtain the range of entry titles and tag titles in the Chef's Diary. You start with the current entry, which is defined as the entry in which the insertion point is currently located. In this method and all of the other methods like it that you have yet to write, the current insertion point is passed into the method as its index in the text storage object.

    In the DiaryDocument.h header file, declare the -currentEntryTitleRangeForIndex: method at the end of the file, just before the @end directive:

    - (NSRange)currentEntryTitleRangeForIndex:(NSUInteger)index;

    In the DiaryDocument.m implementation file, define it like this:

    - (NSRange)currentEntryTitleRangeForIndex:(NSUInteger)index {
         if ([[self diaryDocTextStorage] length] == 0)
               return NSMakeRange(NSNotFound, 0);
         index = [self adjustedIndexIfMarkerAtIndex:index];
         NSUInteger markerIndex = [[[self diaryDocTextStorage] string]
                 rangeOfString:[self entryMarker]
                 options:NSBackwardsSearch
                 range:NSMakeRange(0, index)].location;
         return [self rangeOfLineFromMarkerIndex:markerIndex];
    }

    The first statement takes care of an edge case. If the text storage object is currently empty, it doesn't contain an entry title, so you create and return a range with length 0 whose location is NSNotFound. This conforms to the Cocoa text system convention that a search that comes up empty returns a range whose location member is NSNotFound. This also simplifies the rest of the method's code by eliminating any need to test for an empty text storage object.

    The second statement deals with another edge case. From the user's perspective, if the insertion point is currently located at the entry marker character—that is, immediately before the entry marker and on the same line—it is in the entry marked by that character. In other words, this is the current entry. You force this convention on the text system by defining the search range so that its location member is just after the entry marker. As a result, the backward search finds it immediately. This will also work for forward searches because it prevents the method from finding the marker at the insertion point and allows it to find the following marker for the next entry. You do this in a utility method, -adjustedIndexIfMarkerAtIndex:, which you will write shortly.

    Finally, the method pursues an efficient strategy to locate the current entry's entry marker. You will soon follow a similar strategy in methods designed to search for the next and previous titles. In summary, you start by searching backward from the insertion point (the index argument), looking for the first entry marker you find. If you don't find one, you know the current selection was in an untitled preface near the beginning of the diary, and you exit signaling that a title was not found. If you do find an entry marker, you next search forward from that point, looking for the first newline character you find, or for the end of the file if you don't find a newline character. The newline character or the end of the file defines the length of the entry's title. When returning a range value from the -currentEntryTitleRangeForIndex: method, the location member represents the location of the title marker, and the length member counts the marker and all the characters in the title up to, but not including, the newline character. You must exclude the newline character so that you can use the range even if it ends at the end of the file instead of at a newline character.

    You carry out this strategy in two steps. First, you call an important Cocoa method, -rangeOfString:options:range:, to find the preceding entry marker. Then you call a utility method that you will write shortly, -rangeOfLineFromMarkerIndex: to find the first newline character following the entry marker, or the end of the text storage object's contents if there is no following newline character. The utility method converts this to a range encompassing the entry's title, and you return that range from the -currentEntryTitleRangeForIndex: method. This utility method deals specially with the case where no preceding entry marker is found, returning, as you by now expect, a range whose location member is NSNotFound. The application interprets this to mean that there is no current entry; instead, the insertion point is consider to lie in a preface to the diary preceding its first entry.

    You perform the search for the preceding entry marker using NSString's -rangeOfString:options:range: method. This workhorse text system method is highly optimized and very fast. What you search for is DIARY_TITLE_MARKER, using the -entryMarker method you just wrote. You defined the macro in Step 2 as a string containing the Unicode character with code point 0x270E, a dingbat character representing a pencil.

    If an entry marker is found, the -currentEntryTitleRangeForIndex: method next searches forward from the entry marker, looking for the new-line character that defines the end of the entry's title. It does this using the -rangeOfLineFromMarkerIndex: utility method, which you will write in a moment.

  3. Now write the two utility methods used by -currentEntryTitleRange:ForIndex:. The first is -adjustedIndexIfMarkerAtIndex:.

    Enter this declaration of the method just before the -currentEntryTitleRangeForIndex: method in the DiaryDocument.h header file:

    - (NSUInteger)adjustedIndexIfMarkerAtIndex:(NSUInteger)index;

    Enter the method's definition in the DiaryDocument.m implementation file:

    - (NSUInteger)adjustedIndexIfMarkerAtIndex:(NSUInteger)index {
         return ((index < [[self diaryDocTextStorage] length]) &&
                ([[[self diaryDocTextStorage] string] characterAtIndex:index]
                == [[self entryMarker] characterAtIndex:0]))
                ? index + 1 : index;
    }

    The method first tests whether index is less than the text storage's total length. This test returns NO if the insertion point is at the end of the diary, thus preventing the method from incrementing index to an illegal value. There is no point in searching for a marker character at the end of the file anyway, because it could not mark an entry title.

    The method then tests whether the character at index is an entry marker, and, if it is, increments index. This implements the application's convention, described earlier, that if the insertion point is at the entry marker and on the same line, it is considered to be in the current entry.

  4. Now write the second utility method just after the first.

    In the DiaryDocument.h header file, declare the method as follows:

    - (NSRange)rangeOfLineFromMarkerIndex:(NSUInteger)markerIndex;

    In the DiaryDocument.m implementation file, define it like this:

    - (NSRange)rangeOfLineFromMarkerIndex:(NSUInteger)markerIndex {
         if (markerIndex == NSNotFound) return NSMakeRange(NSNotFound, 0);
         NSRange searchRange = NSMakeRange(markerIndex,
                 [[self diaryDocTextStorage] length] - markerIndex);
         NSUInteger newlineIndex = [[[self diaryDocTextStorage] string]
                 rangeOfString:@"\n" options:0
                 range:searchRange].location;
         if (newlineIndex == NSNotFound) return searchRange;
         return NSMakeRange(markerIndex, newlineIndex - markerIndex);
    }
    

    The method returns a range with a location of NSNotFound and a length of 0 if the incoming index argument is NSNotFound. This is the case if the search in -currentEntryTitleRangeForIndex: found no preceding entry marker.

    If there is a preceding entry marker, the method searches forward for a trailing newline character marking the end of the entry's title. Cocoa does not declare a constant for forward searches on the model of NSBackwardsSearch for backward searches. Just use 0.

    The method has to take into account the possibility that the title is the last line in the diary; that is, that there is no following newline character. It therefore defines the search range to encompass the text storage from the entry marker to the end of the file. If a newline character is not found, it returns the search range, since without a trailing newline character the title must encompass the entire remainder of the text storage. Finally, if a newline character is found, the method returns the current title's range, including its location and its length excluding any trailing newline character.

  5. You can now write the -nextEntryTitleRangeForIndex: method, which, like -currentEntryTitleRangeForIndex:, is needed before you can implement the -currentEntryTagRangeForIndex: and -enterTag: methods. You will find it in the downloadable project file for Recipe 4.

    The -nextEntryTitleRangeForIndex: method is identical to -currentEntryTitleRangeForIndex: except that it searches the portion of the file following the insertion point and the search is forward toward the end of the file. There is no NSForwardsSearch constant like the NSBackwardsSearchconstant, so you use 0.

  6. Now write the -currentEntryTagRangeForIndex:method. It is a little more complicated, because it has to search for the beginning and the end of the current entry before it can determine whether the current entry already has a tag title. To do this, it calls the -currentEntryTitleRangeForIndex: and -nextEntryTitleRangeForIndex:methods you just wrote. You will find it in the downloadable project file for Recipe 4.

    The -currentEntryTagRangeForIndex: method is similar to -currentEntryTitleRangeForIndex: and -nextEntryTitleRangeForIndex:, but the strategy is a little different because the tag title is positioned differently in the text view. The application specification calls for an optional tag title to be inserted on the line immediately following an entry's title. You therefore start by searching backward from the insertion point for an entry marker. There should be no tag title in an untitled preface, and the returned range's location should therefore be NSNotFound if no entry marker is found searching backward. You perform the backward entry marker search using the -currentEntryTitleRangeForIndex: method you just wrote. If an entry marker is found, you next search forward for a following entry marker to define the endpoint of the tag marker search range. You use the -nextEntryTitleRangeForIndex: method you just wrote to perform the forward entry marker search. You then search the range between the two entry markers—or between the first entry marker and the end of the file—for a tag marker and its trailing newline character using code that is nearly identical to the searches for an entry marker and its trailing newline character in the methods you just wrote.

    Another difference in the -currentEntryTagRangeForIndex: method is that it must return the location where a tag list should be inserted if there is no existing tag list in the current entry. In that case, you return a range with a location at the end of the current title string and a length of 0. In the -addTag: action method you are about to write, you will test for a length of 0 in order to choose between inserting a new tag list or simply moving the insertion point to the end of an existing tag list so the user can begin typing tags.

  7. The -addTag: action method calls one more utility method that you have yet to write,-insertionPointIndex:. You have read several times that methods you have just written, like -currentEntryTitleForindex:, take as their index argument the current insertion point in the text view having keyboard focus. Write the method that gets the insertion point now. It belongs in the DiaryWindowController class because it relates to the MVC view.

    At the end of the DiaryWindowController.h header file, insert this declaration:

    - (NSUInteger)insertionPointIndex;

    Define it in the DiaryWindowController.m implementation file:

    - (NSUInteger)insertionPointIndex {
         return [[[[self keyDiaryView] selectedRanges] objectAtIndex:0]
                rangeValue].location;
    }

    In the Cocoa text system, the insertion point is a selection range with a length of 0. When the user clicks the Add Tag button, however, it is entirely possible that a word or phrase may be selected, or even several words or phrases now that the text system supports multiple selection. You must therefore implement a convention defining what the application will consider to be the insertion point and how it will behave when inserting a new tag title. The location of new tag titles is defined in the application specification, so it cannot replace the user's current selection. The insertion point is needed only to know where to begin searching for title markers and tag markers.

    In this method, you treat the location of the first selection in the text view's array of selection ranges as the insertion point. According to the NSTextView Class Reference, the Cocoa text system guarantees that -selectedRanges always returns an array having at least one element, so you do not have to test for these conditions.

  8. At last, you are ready to write the -addTag: action method. This affects the MVC view, so it belongs in the DiaryWindowController class. You will find it in the downloadable project file for Recipe 4.

    The -addTag: action method begins by defining three local variables, keyView , storage , and tagString , following the pattern of -addEntry:. The tagString variable uses the DIARY_TAG_MARKER macro, which you defined at the beginning of this step as the Unicode WHITE FLAG character, with code point 0x2690.

    The method next calls the -currentEntryTagRangeForIndex: method you just finished writing, passing to it the current insertion point. If its location member has the value NSNotFound , the method returns without doing anything because this means the insertion point is currently in an untitled preface. The application specification dictates that an untitled preface cannot hold a valid tag list. You don't really want an action method to do nothing when its button is clicked or its menu item is chosen, because this will confuse the user. You will shortly arrange for the Add Tag button to be disabled in this circumstance. Nevertheless, you should leave this line in the method as a backstop.

    The next line tests the length member of the range returned by the -currentEntryTagRangeForIndex: method. If it is 0, there is no existing tag marker in the current entry and you should insert a new, empty tag title at location. You do this using the same techniques you used in the -addEntry: action method in Step 2, complete with undo support and generation of appropriate notifications. The Edit menu will hold Undo Add Tag and Redo Add Tag menu items. If the length is not 0, then there is already a tag title in the current entry and you don't have to add one.

    Finally, whether you're adding a new tag title or one already exists in the current entry, the method scrolls the text view having keyboard focus to the new or existing tag title and places the insertion pointer appropriately so that the user can begin typing a new tag.

  9. As with every new action method, you must remember to connect it up in Interface Builder. Open the DiaryWindow nib file and Control-drag from the Add Tag button in the diary window to the First Responder proxy in the nib file's window. In the HUD, choose the addTag: action.

  10. You should build and run the application now to test the Add Entry and Add Tag buttons in combination. Open the Chef's Diary window and click the Add Entry button. Immediately after that, click the Add Tag button. In the line following the new entry's title, you see a white flag character and the word Tags: , and the insertion point is located just after that so you can type a tag. Type dessert , appetizer or something similar. Then press the Return key and write up a real or imagined culinary experience, using as many paragraphs as you like. Then click Add Entry and Add Tag again and type some more. Now experiment. Click in the middle of one of the entries and click Add Entry. A new entry appears at the end of the diary, and if the end was scrolled out of view, it scrolls into view. Click again in the middle of any entry and click Add Tag. If that entry already has a tag title, the insertion point moves to its end to let you type another tag.

    Now click at the beginning of the diary, before the first entry's title marker. Type some text, creating an untitled preface to the diary, followed by Return, and click in the new text. Notice that the Add Tag button remains enabled, but when you click it, nothing happens. Now delete all the text from the Chef's Diary window. The Add Tag button is still enabled. Click Add Tag again, and still nothing happens. You will arrange to disable the Add Tag button in either of these circumstances in Step 4.

  • + Share This
  • 🔖 Save To Your Account