views:

146

answers:

2

I've been tinkering with Cocoa for a few months now, and am trying to add undo/redo support to a strictly-for-learning-purposes Cocoa app I'm writing that lets you adjust iTunes track metadata. Thanks to NSUndoManager's prepareWithInvocationTarget: method, I have the basics in place — you can undo/redo changes to the Play Counts and Last Played Dates of selected tracks, for example. (I'm using appscript-objc to get/set the iTunes track data.)

However as updating a large number of iTunes tracks can take a bit of time, I'd like to give the user the ability to cancel an undo/redo operation while it's in progress, but I'm not seeing an obvious mechanism to accomplish this with NSUndoManager. How would I go about doing this?

Edit:
To clarify, having thought about this a little more, I guess what I'm really after is a way to manipulate the undo/redo stacks so that I avoid the "inconsistent state" that Rob Napier mentions in his answer.

So, in response to an undo operation that failed without making any changes (say, before invoking the Undo, the user had opened iTunes' Preferences window, which blocks Apple Events), the undo operation could remain at the top of the undo stack, and the redo stack would be left unchanged. If the operation was cancelled or failed midstream, then I'd like to push onto the redo stack an operation that reverses the changes that went through (if any) and have on top of the undo stack an operation that applies the changes that didn't succeed. I suppose that effectively splitting the operation between the undo and redo stacks could invite user confusion, but it seems to be the most forgiving way of dealing with the issue.

I suspect the answer to this could be one of "Write your own damn Undo Manager", "Your Undo operations shouldn't be able to fail" or "You're overcomplicating this unnecessarily", but I'm curious. :)

+1  A: 

This is a problem for your code, not for NSUndoManager. Whatever method you have requested NSUndoManager call will need to have an interrupt capability. This is no different from canceling the operation when it is initially performed (and in fact, it should be the same code whenever possible). There are two common ways to achieve this, with or without threads.

In the threaded case, you run your undo operation on a background thread, and in every loop check a BOOL such as self.shouldContinue. If it is ever set to false (generally by some other thread), then you stop.

A similar way to achieve this without threads is like this:

- (void)doOperation
{
    if ([self.thingsToDo count] == 0 || ! self.shouldContinue)
    {
        return;
    }

    id thing = [self.thingsToDo lastObject];
    [self.thingsToDo removeLastObject];

    // Do something with thing

    [self performSelector:@selector(doOperation) withObject: nil afterDelay:0];
}

A major issue here is that this cancel will leave your undo stack in an indeterminate state. It's up to you to deal with that. Again, this is no different than the initial case when you created the undo item. However you deal with interrupts then is how you should deal with interrupts during undo. They not are not generally different kinds of actions.

Rob Napier
+1  A: 

I have two answers for you. The first is the common way to handle this:

Call undo when you hit the cancel button and let NSUndoManager roll back all the changes for you.

See: http://www.cimgf.com/2008/04/30/cocoa-tutorial-wiring-undo-management-into-core-data/ http://www.mac-developer-network.com/columns/coredata/coredatafeb09/ for examples of this method. They are discussing sheets but it should apply with anything that is cancelable.

The problem with this method is that it will leave an inconsistent redo stack. You may be able to solve this by evicting the target from NSUndoManager with a call to removeAllActionsWithTarget:

The second solution is a lot more complicated but I actually use it and it works. I am using a ported version in Java so forgive if the example is not in objective-c but I will try to get the concept across anyways.

I create a new undo manager strictly for the operation and set it so it is the "active" one (I think that means setting it on your controller in Cocoa). I keep a reference to the original one so I can set things back to normal when I am done.

If they hit the cancel button you can call undo on your active undo manager, release it and set the original undo manager back to where it was before. The original will not have any undo or redo actions pending other than the ones that were originally there.

If the operation succeeds then it is a bit tricky. At this point you need to registerUndoWithTarget:selector:object:. Here is a little pseudo code of what needs to happen:

invoke(boolean undo) {
    oldUndoManager = currentUndoManager
    setCurrentUndoManager(temporaryUndoManager)

    if (undo)
        temporaryUndoManager.undo()
        oldUndoManager.registerUndo(temporaryUndoManager,
                "invoke", false)
    else
        temporaryUndoManager.redo()
        oldUndoManager.registerUndo(temporaryUndoManager,
                "invoke", true)

    setCurrentUndoManager(oldUndoManager)
}

This will allow your original (old) undo manager to basically call undo/redo on your new (temporary) one and set up the corresponding undo/redo opposite in response. This is a lot more complicated than the first one, but really has helped me to do what I wanted to do.

My philosophy is that only a completed operation should go to the undo manager. A cancelled operation is a operation that for all practical purposes never existed. You won't find that in any Apple documentation I know of though, just my opinion.

rancidfishbreath