How to Fix When Some Text Changes Don't Come with Automatic Undo?

When you work with NSTextView and happen to use insertText(_:) to programmatically insert text, you get an undoable action for free.

This might give the impression you get undo/redo functionality for free.

Eventually, you’ll notice how other changes don’t have an affordance in the “Edit” menu. While it’s possible to get “Undo Typing” and “Undo Set Color” from some function calls, it’s not possible to get “Undo Change Text Attributes” when you use NSTextStorage.addAttributes(_:range:).

That doesn’t mean that addAttributes is broken or you’re holding it wrong – it’s just that some text view methods are exactly what is being used for user-interactive changes, and these come with auto-undo, while actual programmatic API does not.

If you look at the docs for insertText(_:), it says:

This method is the entry point for inserting text typed by the user and is generally not suitable for other purposes. Programmatic modification of the text is best done by operating on the text storage directly. Because this method pertains to the actions of the user, the text view must be editable for the insertion to work.

So this is the same entry point for user-interactive changes, and it adheres to the rules of isEditable. That’s not a good fit for programmatic changes.

But if you call the programmatic API of NSTextStorage, which is just an NSMutableAttributedString, you end up with replaceCharacters(in:with:) and don’t get undo/redo for free anymore.

The good news is that adding undo/redo for programmatic API changes is also very simple.

The default UndoManager is set to group all registered blocks into 1 undoable action for each pass of the RunLoop. That’s the main app’s while-loop that polls for events and continuously draws the app’s contents for you, basically. You are on the same “run loop pass” as long as you don’t enqueue an action asynchronously on a dispatch queue, background thread, or via RunLoop.perform(_:).

As a consequence, when you call UndoManager.registerUndo(withTarget:handler:) in 5 different view controllers all reacting to the same notification, these 5 registered undo blocks will be coalesced into 1 undoable action because they’re in the same group on the same run loop pass.

Bottom-line: If you get undo/redo for free from calling some method on NSTextView, chances are these were meant to be user-interactive and not programmatic API, so you should consider using a different way to achieve your goal via the underlying NSTextStorage. That means you need to register the inverse action with the UndoManager’s undo stack. This can be done anytime and will automatically be grouped into 1 user-undoable action, so you need to do less than you might have feared.

Update 2022-09-14: Matt Massicotte (@mattie) pointed out:

Interacting with NSTextStorage directly will not correctly take into account text selection. How much of an issue this is depends, but definitely something to be aware of.

That’s true, you have to do everything manually, including selection restoration when undoing. Maybe a good post for another day.