2018-02-11

Cocoa text system and delegate messages

Hello, world! This article contains some notes on the Cocoa text system and examples of how delegate messages are called in response to various user actions.

I have been tinkering with an application that uses two views for an object. One is a custom NSView for displaying and manipulating the object graphically. The other is an NSTextView which can mix free-form text with object nodes represented as short text strings. Changes in one view have to be synchronized with the other view. This has been forcing me to learn more about NSTextView behavior and the Cocoa text system in general. I found it hard to visualize the flow of messages based on the scattered documentation pages, so I did some experimenting. The notes below summarize those message flows for various user actions.

Per the Cocoa Text Architecture Guide: "The four primary text system classes are NSTextView, NSLayoutManager, NSTextContainer, and NSTextStorage. In the case of the text system, NSTextStorage holds the model’s text data, NSTextContainer models the geometry of the layout area, NSTextView presents the view, and NSLayoutManager intercedes as the controller to make sure that the data and its representation onscreen stay in agreement."

Selections and attributes

// NSTextViewDelegate
- (NSRange)textView:(NSTextView *)aTextView
	willChangeSelectionFromCharacterRange:(NSRange)oldSelectedCharRange
	toCharacterRange:(NSRange)newSelectedCharRange;

- (void)textViewDidChangeSelection: (NSNotification *) aNotification
	// aNotification = NSTextViewDidChangeSelectionNotification (userInfo[@"NSOldSelectedCharacterRange"])

- (NSDictionary *)textView:(NSTextView *)textView
	shouldChangeTypingAttributes:(NSDictionary *)oldTypingAttributes
	toAttributes:(NSDictionary *)newTypingAttributes

The NSTextViewDelegate protocol includes two methods for selection changes. Maybe you don't care about controlling the selection but you want to know when it changes so that you can update a display somewhere else. In that case, watch for -textViewDidChangeSelection:, then use NSTextView's -selectedRanges method to get the current selection. In my case, I do want the opportunity to modify the selection. For example, if the user clicks inside a node's text, I want to select the whole node. The delegate method -textView:willChangeSelectionFromCharacterRange:toCharacterRange: lets me return a modified range.

The first click in an empty text view calls both selection methods.

textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (0,0)
textViewDidChangeSelection:  -- old range = (0,0)

A selection change is usually followed by a change in typing attributes. This is something that I can use. Each node represented by text in the NSTextView also has an NSLinkAttribute attached to its characters. If the user selects the position immediately after a node and starts typing, I don't want the NSTextView to use that link attribute for the new characters. The textView:shouldChangeTypingAttributes:toAttributes: method gives me a chance to remove link attributes from free-form text.

A double click selects a word, but it triggers two selection changes, one to the character position, then another to the word range.

textView:willChangeSelectionFromCharacterRange:toCharacterRange: (5,0) → (2,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (5,0)

textView:willChangeSelectionFromCharacterRange:toCharacterRange: (2,0) → (0,5)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (2,0)

You may also receive attribute changes by themselves. For example, if your selection has zero length and the user presses Command-B to toggle off bold font style, you could get this message:

textView:shouldChangeTypingAttributes:toAttributes: -- {
	NSParagraphStyle = "..."; NSFont = "Helvetica-Bold 12.00 pt. ...";
} → {
	NSParagraphStyle = "..."; NSFont = "Helvetica 12.00 pt. ...";
}

Preventing changes

// NSTextViewDelegate
- (BOOL)textView:(NSTextView *)aTextView
	shouldChangeTextInRange:(NSRange)affectedCharRange
	replacementString:(NSString *)replacementString
		// replacementString = nil if only attributes are being changed

- (BOOL)textView:(NSTextView *)aTextView doCommandBySelector:(SEL)aSelector

These methods let you prevent certain actions altogether. In my application I insert a symbol into the NSTextView which represents the root node and I do not want that node to be deleted. It is not enough to prevent that text from being selected with textView:willChangeSelectionFromCharacterRange:toCharacterRange:. The user could place the insertion point immediately after the symbol and press backspace. For that example, either method above could be useful. The method -textView:shouldChangeTextInRange:replacementString: lets you examine the range that will be changed and reject the edit altogether. Alternately, you could implement -textView:doCommandBySelector:, watch for any selector that begins with the verb "delete", return YES to claim that you performed the action, but selectively ignore it.

For deleting text, -textView:shouldChangeTextInRange:replacementString: is called with an empty string. That method can also be called with a nil pointer, which means that only the attributes of the given range are being changed. I do not know how to find the proposed new attributes here, but see below.

Modifying changes and reacting to changes

The text view's characters are held by an instance of NSTextStorage, which is a subclass of NSMutableAttributedString, and NSTextStorage provides two useful delegate methods. The documentation for -textStorageWillProcessEditing: says that "the delegate can verify the changed state of the text storage object and make changes to the text storage object’s characters or attributes to enforce whatever constraints it establishes." There is also -textStorageDidProcessEditing: where "the delegate can verify the final state of the text storage object; it can’t change the text storage object’s characters without leaving it in an inconsistent state, but if necessary it can change attributes." Despite Apple's documentation, christiantietze.de shows that it is better to perform attribute changes in -textDidChange: from the NSText protocol instead.

// NSTextStorageDelegate
- (void)textStorageWillProcessEditing:(NSNotification *)aNotification
- (void)textStorageDidProcessEditing:(NSNotification *)aNotification

// NSTextDelegate
- (void)textDidChange:(NSNotification *)aNotification

(For my current purposes, I do not care about the other NSTextDelegate methods. They might be useful if one part of the application had to respond to the fact that an NSTextField was being edited. In my application there is a custom view and an NSTextView that present different versions of the same data, so the text view should always be "on".)

Examples: Simple Edits

So let's look at couple of examples. If the insertion point is at the beginning of a buffer and the user types a lowercase "a", you would see these delegate messages:

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0000, flags = 0x0000, characters = "a" }
textView:shouldChangeTextInRange:replacementString: -- (0,0) → "a"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (1,0)
textViewDidChangeSelection:  -- old range = (0,0)
textDidChange: object = (NSTextView)

If you wanted a text view that automatically capitalized everything, you could implement textStorageWillProcessEditing:, call NSTextStorage's -editedRange method, and convert all text in that range to uppercase. You could emphasize certain words by making them bold or italic. You could go further and implement syntax highlighting here.

If you press backspace to delete the character just inserted, you should see these delegate messages:

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0033, flags = 0x0000 }
textView:doCommandBySelector: -- deleteBackward:
textView:shouldChangeTextInRange:replacementString: -- (0,1) → ""
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (1,0) → (0,0)
textViewDidChangeSelection:  -- old range = (1,0)
textView:shouldChangeTypingAttributes:toAttributes: -- {} → { NSParagraphStyle; NSFont; }
textDidChange: object = (NSTextView)

If you convert a selection to bold using Command-B (as opposed to the zero-length selection example above) you should see these messages. Note that the delegate does not get a -textView:doCommandBySelector: in this case.

// Convert a region to bold via Command-B
textView:shouldChangeTextInRange:replacementString: -- (7,5) → "(null)"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textDidChange: object = (NSTextView)
textView:shouldChangeTypingAttributes:toAttributes: -- {
	NSParagraphStyle = "..."; NSFont = "Helvetica 12.00 pt. ...";
} → {
	NSParagraphStyle = "..."; NSFont = "Helvetica-Bold 12.00 pt. ...";
}

Examples: Cut and paste

If you cut text from a buffer, it looks like a deletion. You can identify a cut by calling the NSApplication -currentEvent method. The event will either be an NSKeyDown with Command-X, or an NSLeftMouseUp with a nil window property for a menu item selection. (The window property will be nil for a context menu item. It does not matter that the cursor is inside the window.) Is this useful to know? Maybe. If the user deletes a node, it means that he wants to be rid of it. If he cuts it, then he probably wants to paste it somewhere else in the document, so you might need to keep some reference to it. You would normally have an Undo stack though and it will keep references to the deleted nodes, so that is not usually important.

// Cut selected text. Note that the delegate does not see a -doCommandBySelector:
//
// If cutting via keyboard (Command-X):
// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0007, flags = 0x100000, characters = "x" }
// If cutting via Edit menu:
// [NSApp currentEvent] = { NSLeftMouseUp, window == nil }

textView:shouldChangeTextInRange:replacementString: -- (0,6) → ""
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,6) → (0,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (0,6)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textDidChange: object = (NSTextView)

Pasting works the same way. If I see that the current activity was triggered by a Command-V or a menu item, my textStorageWillProcessEditing: implementation could scan the edited range looking for link attributes and make appropriate adjustments.

// Paste the word "Hello"
//
// If pasting via keyboard (Command-V):
// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0009, flags = 0x100000, characters = "v" }
// If pasting via Edit menu:
// [NSApp currentEvent] = { NSLeftMouseUp, window == nil }

textView:shouldChangeTextInRange:replacementString: -- (0,0) → "Hello"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (5,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (0,0)
textDidChange: object = (NSTextView)

Examples: Internal Drag and drop

NSTextView allows the user to drag selected text to a different location within the view. I want to support this. I like the idea of re-ordering branches within the tree by dragging them around. It is a little tricky though because there is no explicit notification that a drag is happening. From the delegate's viewpoint, it appears to be four separate activities:

// If the buffer contains the string "Hello, world" with the word "world" selected, and the
// user drags it to the beginning of the line, the drag action is broken into four segments:

// 1. set selection to insertion point
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (7,5) → (0,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (7,5)

// 2. perform insertion and advance insertion point
// [NSApp currentEvent] = { NSLeftMouseUp, window != nil }
textView:shouldChangeTextInRange:replacementString: -- (0,0) → "world "
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (6,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (0,0)
textDidChange: object = (NSTextView)

// 3. expand selection backwards to include inserted text
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (6,0) → (0,6)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textViewDidChangeSelection:  -- old range = (6,0)

// 4. delete original text
textView:shouldChangeTextInRange:replacementString: -- (12,6) → ""
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSParagraphStyle; NSFont } → { NSParagraphStyle; NSFont }
textDidChange: object = (NSTextView)

The distinguishing feature of the internal drag is that the current event is an NSLeftMouseUp where the window property is not nil. (If the window was nil, it would be a menu item response.) I think that is sufficient, but you could also observe the change count of the drag pasteboard ([[NSPasteboard pasteboardWithName: NSDragPboard] changeCount]). You will see this increment first in the -textView:willChangeSelectionFromCharacterRange:toCharacterRange:

It is also possible to drag text from another application into the text view. For an external drag, the current event is an NSAppKitDefined with a nil window property and .subtype = 2.

2-stage Operations

The combination of two delegate methods solves a couple of problems. An attribute-only change reported by textView:shouldChangeTextInRange:replacementString: seems useless, but you could record the existing attributes during that method, then compare the new attributes inside textStorageWillProcessEditing:

You may also have insufficient information during textView:shouldChangeTextInRange:replacementString: to decide whether or not to accept the change. Save the contents of the target range in the first method, then evaluate the results in textStorageWillProcessEditing:. Perform an implicit undo operation if necessary. (TODO: what if this is the only change in a text undo group?)

For some more detail on the Undo implementation, see this page.

Input Methods

When you use an Asian Input Method to type in non-Latin text, you type in one or more Latin characters that are inserted into the text view with an underline, then you can select the appropriate glyph from a numeric list. So for Chinese, to generate the character meaning I/me/my, you would type in the characters "w" and "o", then use "1" to select the first glyph from the list. Those three characters trigger a flood of delegate messages, equivalent to 5-8 edits. I don't how to distinguish them from regular insertions, but most of them appear to be null changes anyway, so I guess it is okay to ignore them.

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x000d, flags = 0x0000, characters = "w" }
textView:shouldChangeTextInRange:replacementString: -- (0,0) -- "w"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (1,0)
textView:shouldChangeTypingAttributes:toAttributes: -- {} → { NSFont }
textViewDidChangeSelection:  -- old range = (0,0)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (1,0) → (1,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSFont } → { NSFont }
textViewDidChangeSelection:  -- old range = (1,0)
textView:shouldChangeTextInRange:replacementString: -- (0,1) -- "w"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (1,0) → (1,0)
textViewDidChangeSelection:  -- old range = (1,0)
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (1,0) → (1,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSFont } → { NSFont }
textViewDidChangeSelection:  -- old range = (1,0)

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x001f, flags = 0x0000, characters = "o" }
textView:shouldChangeTextInRange:replacementString: -- (0,1) -- "wo"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (1,0) → (2,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSFont } → { NSFont }
textViewDidChangeSelection:  -- old range = (1,0)
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (2,0) → (2,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSFont } → { NSFont }
textViewDidChangeSelection:  -- old range = (2,0)
textView:shouldChangeTextInRange:replacementString: -- (0,2) -- "wo"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (2,0) → (2,0)
textViewDidChangeSelection:  -- old range = (2,0)
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (2,0) → (2,0)
textView:shouldChangeTypingAttributes:toAttributes: -- { NSFont } → { NSFont }
textViewDidChangeSelection:  -- old range = (2,0)

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0012, flags = 0x0000, characters = "1" }
textView:shouldChangeTextInRange:replacementString: -- (0,2) -- "我"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (2,0) → (1,0)
textViewDidChangeSelection:  -- old range = (2,0)
textDidChange: object = (NSTextView)