// This looked like the best thread to post this.
// This is incomplete skeleton example of NSDocument/NSUndoManager usage.
// Use at your own risk
// Ultimately, would love if Apple reviewed/corrected this and added correct sample code to their docs.
// I put pseudo code in implementation specific spots:
// "... implementation specific ..."
// "... implement something inherently dangerous... loading your data from a file"
// "... implement something inherently dangerous... commit changes to a file ..."
// "... in my implementation, I prompt user with save/quit options when edit window is closed ..."
//
// Apple's documentation states that NSDocumentController should *rarely* be subclassed,
// but Apple fails to provide sample code to accomplish what you need.
// After trying to subclass NSDocumentController, I decided to rip all that code out.
// What I really needed, in my case, was this:
// @interface NSObject(NSApplicationDelegate)
// - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename
//
// My requirements in a nutshell:
// NSUndoManager, only allow 1 document open at a time (for now), multiple editing windows for 1 document,
// automatically save to temporary file, ability to quit and relaunch without saving,
// Custom "open", "save", "save as", "revert to saved", etc... MyDocumentController
// subclass below provides a simple way to bypass much of the NSDocumentController
// file dialogs, but still use other features of NSDocument (like track unsaved changes).
// Override only "NSDocumentController documentForURL".
/* your NSDocument subclass MUST be defined in your Info.plist.
If you don't, Lion will complain with a NSLog message that looks like this:
-[NSDocumentController openDocumentWithContentsOfURL:display:completionHandler:] failed during state restoration. Here's the error:
Error Domain=NSCocoaErrorDomain Code=256 "The document “blah.myfiletype” could not be opened. cannot open files in the “blah Document” format."
In this example, files of type .myfiletype are associated with my NSDocument subclass
Personally, I hate having to put a class name in my Info.plist, because its not maintainable!
Note to Apple developers: if you are going to force us to do this, then pleasssse make
sure that an XCode search "In Project" for "MyDocument" finds the entry in the plist file!
Oh wow, I just answered my own question: Apple! please make "All candidate files" the
default option for search!
MyDocument:
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>My Funky App File</string>
<key>NSDocumentClass</key>
<string>MyDocument</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>myfiletype</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>My_File_Icon.icns</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
*/
#define kMyFileTypeExtension @"myfiletype"
@interface MyDocument : NSDocument
{
}
- (void) registerForUndoGrouping;
- (NSString *)filePath; // convenience function
- (void) setFilePath: (NSString *)filePath; // convenience function
+ (BOOL) openMyDocument: (NSString *)filename; // class method
@end
extern int gWantsToQuit; // global indicator that it's time to stop drawing/updating
// track my startup state so I can control order of initialization, and so I don't
// waste time drawing/updating before data is available.
enum // MyLaunchStatus
{
kFinishedPreWaking = 0x01, // step 1: applicationWillFinishLaunching called
kFinishedOpenFile = 0x02, // step 2: (optionally) application:openFile: called (important for detecting double-click file to launch app)
kFinishedWaking = 0x04, // step 3: NSApp run loop ready to run. applicationDidFinishLaunching
kFinishedPreLaunchCheck = 0x08, // step 4: error recovery check passed
kFinishedLoadingData = 0x10, // step 5: data loaded
kFinishedAndReadyToDraw = 0x20, // step 6: run loop ready for drawing
};
typedef NSUInteger MyLaunchStatus;
#pragma mark -
@interface MyAppController : NSResponder <MidiProtocol, NSOpenSavePanelDelegate, NSTextFieldDelegate, NSWindowDelegate>
MyDocument *toDocument;
@end
#pragma mark -
@implementation MyDocument
- (id)init
{
if ( !(self = [super init]) ) return self;
return self;
}
- (void) registerForUndoGrouping
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(beginUndoGroup:)
name:NSUndoManagerDidOpenUndoGroupNotification
object:nil];
}
- (void)canCloseDocumentWithDelegate:(id)delegate shouldCloseSelector:(SEL)shouldCloseSelector contextInfo:(void *)contextInfo
{
if ( [[MyAppController instance] windowShouldClose: delegate] )
if ( [delegate respondsToSelector:@selector(close)] )
[delegate close];
//[delegate performSelector:shouldCloseSelector];
//if ( [delegate isKindOfClass:[NSWindow class]] )
// [delegate performClose:self]; // :self];
return; // handled by [[MyAppController instance] windowShouldClose:(id)sender
}
- (void)shouldCloseWindowController:(NSWindowController *)windowController delegate:(id)delegate shouldCloseSelector:(SEL)shouldCloseSelector contextInfo:(void *)contextInfo
{
if ( [[MyAppController instance] windowShouldClose: [windowController window]] )
if ( [[windowController window] respondsToSelector:@selector(close)] )
[[windowController window] close];
}
- (void) beginUndoGroup: (NSNotification *)iNotification
{
NSUndoManager *undoMgr = [self undoManager];
if ( [undoMgr groupingLevel] == 1 )
{
// do your custom stuff here
}
}
// convenience functions:
- (NSString *)filePath { return [[self fileURL] path]; }
- (void) setFilePath: (NSString *)filePath
{
if ( [filePath length] )
[self setFileURL:[NSURL fileURLWithPath: filePath]];
else
[self setFileURL:nil];
}
- (BOOL)validateUserInterfaceItem:(id <NSValidatedUserInterfaceItem>)anItem
{
if ( [self isDocumentEdited] && [anItem action] == @selector(revertDocumentToSaved:) )
return YES;
BOOL retVal = [super validateUserInterfaceItem:(id <NSValidatedUserInterfaceItem>)anItem];
return retVal;
}
- (IBAction)revertDocumentToSaved:(id)sender
{
NSInteger retVal = NSRunAlertPanel(@"Revert To Saved?", [NSString stringWithFormat: @"Revert to Saved File %@?", [self filePath]], @"Revert to Saved", @"Cancel", NULL);
if ( retVal == NSAlertDefaultReturn )
[[MyAppController instance] myOpenFile:[self filePath]];
}
+ (BOOL) openMyDocument: (NSString *)filename
{
if ( ![[filename pathExtension] isEqualToString: kMyAppConsoleFileExtension] )
return NO;
// If the user started up the application by double-clicking a file, the delegate receives the application:openFile: message FIRST
MyLaunchStatus launchStatus = [[MyAppController instance] isFinishedLaunching];
BOOL userDoubleClickedToLaunchApp = !( launchStatus & kFinishedPreLaunchCheck );
MyDocument *currDoc = [[MyAppController instance] document];
NSString *currPath = [currDoc filePath];
NSInteger retVal;
NSLog( @"open file %@ currPath %@ launchStatus %d", filename, currPath, launchStatus );
if ( userDoubleClickedFileToLaunchApp )
{
// user double-clicked a file to start MyApp
currPath = [[NSUserDefaults standardUserDefaults] objectForKey:@"LastSaveFile"];
if ( [currPath isEqualToString: filename] )
{
sWasAlreadyOpen = YES;
if ( [[[NSUserDefaults standardUserDefaults] objectForKey:@"isDocumentEdited"] boolValue] == YES )
{
retVal = NSRunAlertPanel(@"Open File", @"Revert to Saved?", @"Revert to Saved", @"Keep Changes", @"Quit", NULL);
if ( retVal == NSAlertDefaultReturn )
{
[[MyAppController instance] myOpenFile:filename];
}
else if ( retVal == NSAlertOtherReturn )
exit(0);
}
// proceed with normal startup
if ( currDoc )
return YES;
else
return NO;
}
}
if ( !(launchStatus & kFinishedPreLaunchCheck ) ) // not done launching
return YES; // startup in whatever state we were before
if ( [currPath isEqualToString: filename] )
{
sWasAlreadyOpen = YES;
NSLog( @"is edited %d currDoc %@", [currDoc isDocumentEdited], currDoc );
if ( [currDoc isDocumentEdited] )
[currDoc revertDocumentToSaved:self]; // will prompt
else // document is already open, so do what Apple's standard action is...
[currDoc showWindows];
}
else
{
if ( [currDoc isDocumentEdited] )
retVal = NSRunAlertPanel(@"Open File", [NSString stringWithFormat: @"The current file has unsaved changes. Discard unsaved changes and switch to file '%@'?", filename], @"Discard unsaved changes and switch to file", @"Keep Current", NULL);
else
retVal = NSRunAlertPanel(@"Switch to File", [NSString stringWithFormat: @"Switch to File '%@'?\n\nCurrent file '%@'", filename, currfilePath ? currfilePath : @"Untitled"], @"Switch", @"Keep Current", NULL);
if ( retVal == NSAlertDefaultReturn )
[[MyAppController instance] myOpenFile:filename];
}
// user cancelled
if ( currDoc )
return YES;
else
return NO;
}
// Note: readFromURL is here for completeness, but it should never be called,
// because we override NSDocumentController documentForURL below.
- (BOOL)readFromURL:(NSURL *)absoluteURL ofType:(NSString *)typeName error:(NSError **)outError
{
if ( outError )
*outError = nil;
if ( ![typeName isEqualToString: kMyFileTypeExtension ] ) //
return NO;
return YES;
}
// Note: writeToURL is here for completeness, but it should never be called,
// because we override NSDocumentController documentForURL below.
- (BOOL)writeToURL:(NSURL *)absoluteURL ofType:(NSString *)typeName error:(NSError **)outError
{
if ( outError )
*outError = nil;
return YES;
}
@end
// kpk migrating slowly toward NSDocument framework
// (currently most functionality is in MyAppController)
// Must bypass default NSDocumentController behavior to allow only 1 document
// and keep MyAppController responsible for read, write, dialogs, etc.
@implementation MyDocumentController
// this should be the only override needed to bypass NSDocument dialogs, readFromURL,
// and writeToURL calls.
// Note: To keep Lion happy, MainInfo.plist and Info.plist must define "MyDocument" for key "NSDocumentClass"
- (id)documentForURL:(NSURL *)absoluteURL
{
MyDocument *currDoc = [[MyAppController instance] document];
if ( [[currDoc filePath] isEqualToString: [absoluteURL path]] )
return currDoc;
else
return nil;
}
@end
#pragma mark -
@implementation MyAppController
static MyAppController *sInstance;
+ (MyAppController *)instance
{
return sInstance; // singleton... why is this not in all Apple's sample code?
}
// called by main.mm before MyAppController (or NSApp for that matter) is created.
// need to init some global variables here.
+ (void) beforeAwakeFromNib
{
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
... implementation specific ...
// disable fancy stuff that slows launch down
[[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:NO] forKey: @"NSAutomaticWindowAnimationsEnabled"];
[pool release];
}
- (void) awakeFromNib
{
NSLog(@"MyAppController awake\n");
sInstance = self;
[toWindow setNextResponder:self];
[NSApp setDelegate:self];
[self addWindowToDocument:toWindow];
}
- (MyDocument *)document
{
if ( !toDocument )
{
toDocument = [[MyDocument alloc] init];
}
return toDocument;
}
- (NSUndoManager *)undoManager
{
// !!! WARNING: there are multiple NSUndoManager's in this App
// Note: when an editable text field is in focus,
// NSTextField will create
// a separate undo manager for editing text while that field is in focus.
// This means that hitting undo/redo while editing a text field will not go beyond the scope of that field.
// This will return the global undo manager if the keyWindow was registered
// via [self addWindowToDocument:];
// Windows which are NOT part of the document (such as preferences, popups, etc.), will
// return their own undoManager, and undo will do nothing while those windows are in front.
// You can't undo preferences window changes, so we don't want to surprise the user.
NSUndoManager *undomgr = [[NSApp keyWindow] undoManager];
if ( undomgr )
{
static bool sFirstTime = true;
if ( sFirstTime )
{
sFirstTime = false;
[undomgr setLevelsOfUndo:1000]; // set some sane limit
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(beginUndo:)
name:NSUndoManagerWillUndoChangeNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(beginUndo:)
name:NSUndoManagerWillRedoChangeNotification
object:nil];
[toDocument registerForUndoGrouping];
// [[NSNotificationCenter defaultCenter] addObserver:self
// selector:@selector(endUndo:)
// name:NSUndoManagerDidUndoChangeNotification
// object:nil];
// [[NSNotificationCenter defaultCenter] addObserver:self
// selector:@selector(endUndo:)
// name:NSUndoManagerDidRedoChangeNotification
// object:nil];
}
}
return undomgr;
}
- (void) showStatusText: (id)iStatusText
{
... implementation specific ...
}
- (void) beginUndo:(id)sender
{
// implementation specific stuff here
NSUndoManager *undomgr = [[NSApp keyWindow] undoManager];
if ( [sender object] == undomgr )
{
if ( [undomgr isUndoing] )
[self showStatusText: [NSString stringWithFormat:@"Undo %@", [undomgr undoActionName]]];
else if ( [undomgr isRedoing] )
[self showStatusText: [NSString stringWithFormat:@"Redo %@", [undomgr redoActionName]]];
}
}
// Add a window (with a window controller) to our document, so that the window
// uses the document's NSUndoManager. In the future, we may want to use other features of NSDocument.
- (void)addWindowToDocument:(NSWindow *)iWindow
{
NSString *autosaveName = [iWindow frameAutosaveName]; // preserve for "mainWindow", others.
NSWindowController *winController = [iWindow windowController];
if ( !winController )
winController = [[NSWindowController alloc] initWithWindow:iWindow];
// create document if needed, and add window to document.
[[self document] addWindowController: winController];
if ( autosaveName )
[iWindow setFrameAutosaveName:autosaveName]; // restore original for "mainWindow", others.
[winController setNextResponder:self]; // keep last hotkey destination... see keyDown:
}
- (void) myOpenFile:(NSString*)path
{
// this is just a skeleton of what I do to track unsaved changes between relaunches
[toDocument setFilePath:path];
... implementation specific ...
[[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:NO] forKey:@"isDocumentEdited"];
[toDocument updateChangeCount:NSChangeCleared];
[[NSUserDefaults standardUserDefaults] setObject:[toDocument filePath] forKey:@"LastSaveFile"];
BOOL success = [[NSUserDefaults standardUserDefaults] synchronize]; // bootstrap
// kpk very important... resetStandardUserDefaults forces the immutable
// tree returned by dictionaryWithContentsOfFile to be mutable once re-read.
// Apple: "Synchronizes any changes made to the shared user defaults object and releases it from memory.
// A subsequent invocation of standardUserDefaults creates a new shared user defaults object with the standard search list."
[NSUserDefaults resetStandardUserDefaults];
NSString *name = [[NSUserDefaults standardUserDefaults] objectForKey:@"LastSaveFile"];
}
- (void) mySaveData:(NSString*)path
{
// this is just a skeleton of what I do to track unsaved changes between relaunches
@try
{
... implement something inherently dangerous... commit changes to a file ...
if ( !errorStr )
{
if ( [toDocument isDocumentEdited] )
{
// UInt64 theTimeNow = VMPGlue::GetMilliS();
[[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:NO] forKey:@"isDocumentEdited"];
[[NSUserDefaults standardUserDefaults] synchronize]; // bootstrap
// DLog( @"synchronize success %d ms", (int)(VMPGlue::GetMilliS() - theTimeNow) );
}
// tbd consider MyDocument saveToURL:ofType:forSaveOperation:error:
[toDocument updateChangeCount:NSChangeCleared];
}
@catch (...)
{
... run critical alert ...
}
}
- (void) finishLoadingData
{
@try
{
if ( dataexists )
{
... implement something inherently dangerous... loading your data from a file
[toDocument setFilePath: [[NSUserDefaults standardUserDefaults] objectForKey:@"LastSaveFile"]];
NSNumber *num = [[NSUserDefaults standardUserDefaults] objectForKey:@"isDocumentEdited"];
if ( [num boolValue] == YES )
[toDocument updateChangeCount:NSChangeDone];
}
else
{
[[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:NO] forKey:@"isDocumentEdited"];
[toDocument updateChangeCount:NSChangeCleared];
}
sFinishedLaunching |= kFinishedLoadingData;
}
@catch (...)
{
// !!! will not return !!!
... run critical alert ...
// !!! will not return !!!
}
}
#pragma mark NSApplication delegate
// Apple: Sent directly by theApplication to the delegate. The method should open the file filename,
//returning YES if the file is successfully opened, and NO otherwise.
//If the user started up the application by double-clicking a file, the delegate receives the application:openFile: message before receiving applicationDidFinishLaunching:.
//(applicationWillFinishLaunching: is sent before application:openFile:.)
- (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename
{
BOOL didOpen = [MyDocument openMyDocument: filename];
sFinishedLaunching |= kFinishedOpenFile;
return didOpen;
}
// NSApplication notification
- (void) applicationDidFinishLaunching:(NSNotification*)note
{
// kpk note: currentEvent is often nil at this point! [[NSApp currentEvent] modifierFlags]
CGEventFlags modifierFlags = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState);
sFinishedLaunching |= kFinishedWaking;
if ( modifierFlags & (kCGEventFlagMaskShift | kCGEventFlagMaskCommand) )
{
... implementation specific ... alert: @"Shift or Command key held down at startup.\nWhat would you like to do?"
title: @"Startup Options"
canContinue: @"Continue" ];
}
sFinishedLaunching |= kFinishedPreLaunchCheck;
[self finishLoadingData];
sFinishedLaunching |= kFinishedAndReadyToDraw;
}
- (BOOL)windowShouldClose:(id)sender
{
if ( [sender isKindOfClass: [NSWindow class]] && sender != toWindow )
return YES; // allow non-document-edit windows to close normally
... in my implementation, I prompt user with save/quit options when edit window is closed ...
return NO;
}
- (NSApplicationTerminateReply) applicationShouldTerminate:(NSApplication*)sender
{
if ( !gWantsToQuit && [toDocument isDocumentEdited] )
{
if ( ![self windowShouldClose:self] )
return NSTerminateCancel;
}
return NSTerminateNow;
}
- (void) applicationWillTerminate:(NSNotification *)notification
{
if ( gWantsToQuit )
{
... implementation specific ... dont save potentially wonky data if relaunch is required
}
else
{
[self saveData: [toDocument filePath]];
}
}
@end