What Cocoa Does

Cocoa provides excellent support for multiple Undo/Redo. If you’re using the document-based application architecture, this is tied directly to the mechanism for tracking the document changed status.

Multiple Undo/Redo is built-in for NSTextView, including all of the individual actions of Typing, Cut, Paste, Drag, etc.

A control such as a text field which provides editable text does not itself do any editing. Instead, an NSTextView object is superimposed over the field to handle the editing. This view object is positioned to exactly match the text in the original field, so it presents a convincing illusion that the field’s text is being edited directly. By default, Cocoa provides one such “field editor” object per window, which is moved from control to control as needed. Some controls, such as text fields, contain one cell with editable text. Others, such as forms, contain multiple cells. Still others, such as tables, contain a dynamically varying, unbounded number of rows and columns.

But still, one field editor is sufficient for most purposes because only one control, and one cell within that control can be actively edited at a time. That cell is the current cell and is identified visually by a focus ring. However, it is the superimposed field editor which is actually the first responder and therefore the target for events such as keystrokes.

Note: For a discussion of a situation in which one field editor is not sufficient, go to the topic Drag and Drop for Controls.

The Problem

Undo/Redo support in the NSTextView object serving as a field editor is disabled by default. If you enable it, you will get the full benefit of undo/redo for all of the individual editing actions mentioned above.

However, enabling undo/redo for the field editor creates numerous complications that must be dealt with. It is no surprise that Apple has chosen to disable undo/redo for field editors.

1. In part, these complications stem from the fact that a single field editor is being moved around to handle editing in various fields. We do not want actions to remain in the undo and redo stacks for a field which was previously being edited. Applying those actions to the field editor would certainly mess up the value currently being edited.

2. Additional complications arise from the fact that controls generally have some semantic significance beyond simply holding editable text. Pressing Enter, tabbing to another field, clicking elsewhere, or doing anything else which shifts the focus, will cause the edits to be “committed” and will generally trigger an action method. Once committed, it is normally the single action of changing the value contained in the field that we want to be able to undo, not the individual editing steps that were taken along the way. So, for example, we would prefer “Undo Rename” to a sequence of “Undo Typing”, “Undo Paste”, and “Undo Drag” steps.

3. Another complication is that these fields can have formatters attached to them. If the formatter changes the field “on-the-fly”, that is, if it modifies the string as the user edits it, then the formatter’s changes would have to be recorded in the undo stack along with the individual editing actions. Apple has not provided any way to accomplish this.

The first two issues can be dealt with by cleaning up the undo and redo stacks when the edits are committed. The final issue is a show-stopper. Unless Apple modifies Cocoa to deal with on-the-fly changes by a formatter, it is not possible to use the built-in undo/redo capability of NSTextView for such fields. (For more on this, go to the topic Possible Enhancements to Cocoa.)

My Approach

The choices are (1) don’t use on-the-fly formatting (2) don’t support any undo at all for fields with on-the-fly formatting, (3) support single undo for fields with on-the-fly formatting and multiple undo for other fields, or (4) support single undo for all fields.

I have built a solution based on choice (4), which I feel balances the engineering trade-offs and presents a consistent user interface. I’ve chosen to give up the ability to undo each of the individual changes within a field in exchange for the ability to undo all the changes back to the original value when editing began, and to do this in a uniform way for any field, even those for which on-the-fly formatting is performed.

I would still prefer to provide multiple undo within the field, but single undo is adequate as a trade-off because most fields are fairly short. Undoing all the changes and starting over is certainly better than not being able to undo at all.

Please note that the current implementation does not provide multiple undos within a field, but multiple undo operations remain fully available on the stack. It’s just that the individual editing operations within the field are combined into a single undo action which reverts the field to its value prior to editing. Once the edits are committed, we can rely on the undo action posted via the control’s action method (probably at the model level) to handle subsequent multiple undo/redo.

The Solution

I’ve created a class called TZEditingUndoer. An object of this class serves as a helper to take care of posting undo and redo actions while editing is in progress using a field editor. The field editor’s built-in undo/redo remains disabled.

To date, I have tested this solution with the following subclasses of NSControl: NSTextField, NSComboBox, NSForm, and NSTable. I expect it will also work with NSSecureTextField, NSOutlineView, and NSBrowser, but have not tested these as yet.

Using this Solution in Your Application

The only changes required to make use of this solution are to designate a control delegate in Interface Builder, add a few lines of code to create an undoer, and configure a few settings. A TZEditingUndoer may be created once and re-used or created and released for each editing session.

The following methods are declared in the interface to TZEditingUndoer.

- (id) initWithFieldEditor:(NSText*)editor; - (void) setDocument:(NSDocument*)doc; - (void) setUndoManager:(NSUndoManager*)undo; - (void) setActionName:(NSString*)string;

The undoer is associated with a particular field editor when it is initialized. This can not be changed thereafter. The other settings may be modified depending on which control is to be edited.

One or the other of two set up methods must be called: setDocument: or setUndoManager:. Call setDocument: if changes to the control are intended to affect the document change state. The undoer will post actions to the document’s undo manager so it can properly track changes. On the other hand, call setUndoManager:, specifying some other undo manager, if changes to the control are not intended to affect the document change state. The most recent setting is used, so you can re-use the undoer and change the way it functions depending on which control is being edited.

One additional setting is optional, but recommended. Call setActionName: to specify an action name for use in the Undo and Redo menu items. If you do not provide an action name, the menu items will be generic — “Undo” and “Redo”.

When the edits are committed, the undoer object removes from the stacks any undo or redo actions posted while the editing was in progress. Once we’re done editing, we no longer want to be able to undo or redo the editing operations. Committing the edits will trigger the control’s the action method, which is then responsible for posting an undo action if the field value has been modified. This change will likely be at the model level if you’re following the model-view-controller paradigm.

If you should choose an implementation in which a temporary undoer object is created and destroyed, it is likely that you will detect the end of editing via a notification such as NSTextDidEndEditingNotification. If that’s the case, use autorelease, not release. The undoer must complete the stack clean up described in the previous paragraph before it is deallocated. Since this is triggered by a similar notification, autorelease gives the notification process a chance to complete successfully.

Sample Code

This is an example of the code you would implement in your application to make use of TZEditingUndoer. Adapt it as necessary according to your own needs.

In this example, I make the following assumptions:

All of the editable controls are intended to affect the document change state.

Each control with editable text has been connected (in Interface Builder) to the window controller as its delegate. This makes it easy to re-use a single undoer object for all of the controls in the window — they share the same field editor, and the same delegate sets up the undoer. If your controls point to distinct delegates, you will probably want to create a temporary undoer instead.

In the header file for the window delegate, an instance variable has been added to save the undoer object for re-use.

@class TZEditingUndoer; ... TZEditingUndoer *currentEditingUndoer;

The window controller allocates and initializes an undoer object lazily when first needed, and then re-uses this same object whenever editing begins for a text field or other control. The control delegate method control:textShouldBeginEditing: is called prior to beginning the editing process for any control. This is a convenient place to set up the undoer.

#import "TZEditingUndoer.h" ... - (BOOL) control:(NSControl*)control textShouldBeginEditing:(NSText*)fieldEditor { if (!currentEditingUndoer) { currentEditingUndoer = [[TZEditingUndoer alloc] initWithFieldEditor:fieldEditor]; [currentEditingUndoer setDocument:[self document]]; } NSString *controlName = [self controlName:control]; NSString *actionName; if (controlName == nil) actionName = NSLocalizedString(@"Changes to Field", @"Name of undo/redo menu item for editing unnamed text field"); else actionName = [NSString stringWithFormat: NSLocalizedString(@"Changes to %@", @"Name of undo/redo menu item for editing named text field"), controlName]; [currentEditingUndoer setActionName:actionName]; return YES; }

Note that setDocument: is called immediately after allocating and initializing the undoer. In this example the document is the same for all of the controls in the window, so this setting will not be modified.

The action name is set differently for each control, using a control name obtained by calling the utility method controlName: (not shown). In your application, customize the action names however you wish. Whatever string you provide should be localized, since it is presented to the user in the menu as “Undo (action name)” or “Redo (action name)”.

Finally, the window controller releases the undoer object in its dealloc method.

[currentEditingUndoer release];

Digging Deeper

Only one of the source files is used in this solution. Read the detailed description if you want to explore how it works:

TZEditingUndoer