2018-02-11

Cocoa text system and Undo

The Undo implementation in the text system is a bit different than the rest of the system. When the user types consecutive characters, the system combines them into a single undo event. How does it do that? It uses a private class called NSUndoTyping. Although the API for this class is not public, we can use the Objective-C runtime to get a list of methods. There are only five:

-(id) initWithAffectedRange: (NSRange) r1 layoutManager: (id) lmgr undoManager: (id) umgr replacementRange: (NSRange) r2;
-(void) dealloc();

-(BOOL) isSupportingCoalescing();
-(BOOL) coalesceAffectedRange: (NSRange) r1 replacementRange: (NSRange) r2 selectedRange: (NSRange) r3 text: (id) p;

-(void) undoRedo: (id) sender;

When the user types the first character of a sequence, the text view registers an undo event consisting of an NSUndoTyping instance. On later characters, however, the text view does not talk to the Undo manager. Instead, it updates the previous NSUndoTyping entry using the -coalesceAffectedRange:replacementRange:selectedRange:text: method. The log sequence below includes NSUndoManager calls and notifications, but not the individual calls to NSUndoTyping. (For details on the rest, see this page.)

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


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

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

// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0006, flags = 0x100000, characters = "z" }
// = Command-Z Undo

UNDO: undo
NSUndoManagerCheckpointNotification
UNDO: undoNestedGroup
NSUndoManagerCheckpointNotification
NSUndoManagerWillUndoChangeNotification

textView:shouldChangeTextInRange:replacementString: -- (0,3) → ""
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (3,0) → (0,0)
textViewDidChangeSelection:  -- old range = (3,0)
textDidChange: object = (NSTextView)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (0,0)
textViewDidChangeSelection:  -- old range = (0,0)
UNDO: -registerUndoWithTarget:selector: _undoRedoTextOperation: object: (NSUndoTyping)
NSUndoManagerCheckpointNotification
NSUndoManagerDidUndoChangeNotification


// [NSApp currentEvent] = { NSKeyDown, window != nil, keyCode = 0x0006, flags = 0x120000, characters = "z" }
// = Shift-Command-Z Redo

NSUndoManagerCheckpointNotification
NSUndoManagerCheckpointNotification
UNDO: redo
NSUndoManagerCheckpointNotification
NSUndoManagerWillRedoChangeNotification

textView:shouldChangeTextInRange:replacementString: -- (0,0) → "abc"
textStorageWillProcessEditing: object = (NSConcreteTextStorage)
textStorageDidProcessEditing: object = (NSConcreteTextStorage)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (0,0) → (3,0)
textView:shouldChangeTypingAttributes:toAttributes: -- {...} → {...}
textViewDidChangeSelection:  -- old range = (0,0)
textDidChange: object = (NSTextView)
textView:willChangeSelectionFromCharacterRange:toCharacterRange: (3,0) → (3,0)
textView:shouldChangeTypingAttributes:toAttributes: -- {...} → {...}
textViewDidChangeSelection:  -- old range = (3,0)
UNDO: -registerUndoWithTarget:selector: _undoRedoTextOperation: object: (NSUndoTyping)
NSUndoManagerCheckpointNotification
NSUndoManagerCheckpointNotification
NSUndoManagerDidRedoChangeNotification

This maneuver does improve the experience for the user, but it makes life difficult for the delegate method writer. Perhaps one needs to subclass NSUndoManager?