The only convenient way for a user to rename a document is with the Finder. (The Save As command doesn't rename a document; it creates a copy of the document with a new name.) As you've just seen, name changes made in the Finder aren't automatically reflected in an open document window. Another change that's often not picked up by the application is when the user moves the document to a different folder. The code in this article helps synchronize your application's documents with their corresponding files, so that a document will respond to changes made outside the application to its file's name or location.
This article also describes how to prevent a duplicate window from being opened if the user opens an already open document in the Finder and how to add a pop-up menu to the document title bar to help the user determine where the file is stored. All the code for implementing these features is provided on this issue's CD, along with a sample application that illustrates its use.
When I first started looking at the problem of document synchronization, I assumed that the animated example in the Electronic Guide to Macintosh Human Interface Design was the way to go. In this animation, the application checks for a name change when it receives a resume event. However, I became uncomfortable with this approach, because it would cause a delay between the user's changing the name of the document in the Finder and the application's updating the window title. Using a resume event relies on a separate action by the user, namely, bringing the application to the foreground. This seemed nonintuitive and didn't support the illusion that a window and its icon represent a single object. Also, it's possible that with Apple events and AppleScript an application could be launched, do some work, and quit without ever being frontmost -- that is, without ever receiving a resume event.
The truth is that these days, with multiple applications running at the same time, with networked, shared disks everywhere, and with applications and scripts pulling the puppet strings as often as users, a file's name or location may change at any time, whether the application is in the foreground or the background. A script might move or rename a file or, if the file is on a shared volume, another user on the network could move or rename it or even put the file in the Trash -- all behind the application's back. The only solution I found under the current system software was to regularly look at the file to see if its name or location has changed. In other words, the application has to poll for changes.
Polling is generally a bad idea, but there are cases when it's the only reasonable way to accomplish a task, and this is one of them. However, I tried to keep the polling very "lightweight" and low impact by using the following guidelines:
While the code presented here is specific to my implementation, you can easily generalize it as needed. The code below shows how your application might call DSSyncWindowsWithFiles, a routine that keeps your documents synchronized with the Finder by checking for and handling changes made outside the application to file names or locations. Call the routine from within your main event loop when you receive an event (including null events). Note that error checking has been removed from the code shown in the article, but it does appear on the CD.
while (!done) { gotEvent = WaitNextEvent(everyEvent, &theEvent, gSleepTime, theCursorRegion); if (gotEvent) DoEvent(&theEvent); DSSyncWindowsWithFiles(kDontForceSynchronization); }This minor change does most of the work for your application. The machinery that makes it happen lies within DSSyncWindowsWithFiles (see Listing 1). This routine first checks to make sure that enough time has passed since the last check for changes. If so, or if the caller requested immediate synchronization, it iterates through each of the windows registered in the document list, calling DSSyncWindowWithFile to process each of these windows.
Listing 1. DSSyncWindowsWithFiles
#define kCheckTicks 60 pascal void DSSyncWindowsWithFiles(Boolean forceSync) { WindowPtr theWindow; static long theTicksOfLastCheck = 0; long theTicks; theTicks = TickCount(); if (theTicks > (theTicksOfLastCheck + kCheckTicks) || forceSync) { theTicksOfLastCheck = theTicks; for (theWindow = DSFirstWindow(); theWindow != nil; theWindow = DSNextWindow(theWindow)) { DSSyncWindowWithFile(theWindow); } } }DSSyncWindowWithFile, shown in Listing 2, begins by getting the file reference number for the window from the document list. If it's appropriate to continue (DoSyncChecks returns true), DSSyncWindowWithFile calls three other routines to handle name changes, changes that move the file to a different folder, and changes that move the file to the Trash.
Listing 2. DSSyncWindowWithFile
pascal void DSSyncWindowWithFile(WindowPtr aWindow) { short theFRefNum; DSGetWindowDFRefNum(aWindow, &theFRefNum); if (DoSyncChecks(theFRefNum, aWindow)) { HandleNameChange(theFRefNum, aWindow); HandleDirectoryChange(theFRefNum, aWindow); HandleMoveToTrash(theFRefNum, aWindow); } }
Listing 3. DoSyncChecks
static Boolean DoSyncChecks(short aRefNum, WindowPtr aWindow) { Boolean doCheck = false; unsigned long theLastDate, theDate; short theVRefNum; if (aRefNum != 0) { DSGetWindowFileVRefNum(aWindow, &theVRefNum); GetVolumeModDate(theVRefNum, &theDate); DSGetWindowVLsBkUp(aWindow, &theLastDate); if (theLastDate != theDate) { DSSetWindowVLsBkUp(aWindow, theDate); doCheck = true; } } return doCheck; }
void HandleNameChange(short aFRefNum, WindowPtr aWindow) { Str255 theTitle, theName; GetWTitle(aWindow, theTitle); GetNameOfReferencedFile(aFRefNum, theName); if (!EqualString(theTitle, theName, true, true)) SetWTitle(aWindow, theName); }
Listing 5. HandleDirectoryChange
void HandleDirectoryChange(short aFRefNum, WindowPtr aWindow) { long theOldParID, theNewParID; DSGetWindowFileParID(aWindow, &theOldParID); GetFileParID(aFRefNum, &theNewParID); if (theOldParID != theNewParID) DSSetWindowFileParID(aWindow, theNewParID); }
Listing 6. HandleMoveToTrash
static void HandleMoveToTrash(short aFRefNum, WindowPtr aWindow, Boolean *inTrashCan) { FSSpec theFile; Boolean inBackground; short theResponse; EventRecord theEvent; FileInTrashCan(aFRefNum, inTrashCan); if (*inTrashCan) GetFileSpec(aFRefNum, &theFile); if ((aFRefNum != 0) && *inTrashCan) { if (DSIsWindowDirty(aWindow)) { InBackground(&inBackground); if (inBackground) { DSNotify(); do { InBackground(&inBackground); if (WaitNextEvent(everyEvent, &theEvent, gSleepTime, nil)) DoEvent(&theEvent); FileInTrashCan(aFRefNum, inTrashCan); } while (inBackground && *inTrashCan); DSRemoveNotice(); } if (*inTrashCan) { ParamText(theFile.name, "\p", "\p", "\p"); theResponse = Alert(rCloseAlert, nil); switch (theResponse) { case kSave: DoSave(aWindow); /* Fall through */ case kDontSave: ZoomWindowToTrash(aWindow); DoCloseCommand(aWindow); break; case kPutAway: DSAESendFinderFS(kAEFinderSuite, kAEPutAway, &theFile); *inTrashCan = false; break; } } } else /* Window is clean; just close it */ DoCloseCommand(aWindow); } }Now if this were the Finder, there would be no question of what to do in this situation. When the user drags the icon for a folder to the Trash, the folder is essentially gone, so the associated window doesn't remain on the desktop. In the application world, life is a little more problematic. What happens if there are unsaved changes in the document? If the application blindly closes the document when the user drags the icon to the Trash, data could be lost. This would be a Bad Thing.
My mother always told me, "When in doubt, ask." So if there are unsaved changes to the file, an alert gives the user three choices: Don't Save, Remove From Trash, and Save. The Save and Don't Save options are simple: each closes the window as expected. Remove From Trash is a little tricky and takes advantage of the Scriptable Finder and Apple events.
The Remove From Trash case is similar to the Finder situation in which the user decides not to throw the document in the Trash and chooses Put Away from the File menu. HandleMoveToTrash handles this change of mind the same way the Finder handles it with Put Away: it sends the Finder a Put Away Apple event specifying the file in question as the target. (If the Scriptable Finder isn't available, the same action can be simulated manually; see the code on the CD for details.)
Many applications create a new window when an already open document is opened again in the Finder. But if the Finder were to open a second copy of a folder when you double-click the icon of a folder that's already open, wouldn't you be surprised? One of the guiding principles of human interface design is consistency; if your application doesn't perform the same action as the Finder (in this case, bring an already open window to the front), the user must learn and remember what will happen in each particular situation. This detracts from the user's happiness with your application.
Making your application notice that the document is already open is easy if you're using the document list. The following code would appear where you normally call your open-file routine. When the application receives an event to open a file, it checks to see if the file is already registered in the document list. If it's registered, the application simply brings it to the front instead of opening it again.
if (DSFileInDocumentList(aFile, &theWindow)) SelectWindow(theWindow); else DoOpenFile(aFile);
Figure 1. The Finder's pop-up navigation menu
To provide a pop-up navigation menu for your document windows, replace the existing call to FindWindow in your mouse-down event handler with a call to the DSFindWindow routine. DSFindWindow is simply a wrapper for the Window Manager's FindWindow routine. If FindWindow returns inDrag, DSFindWindow does some additional checking to determine whether the window is frontmost, the Command key is down, and the mouse is in the window title area. If the mouse-down event meets these conditions, DSFindWindow calls DSPopUpNavigation, which implements the menu and returns inDesk as the window part, telling the application to ignore the click.
Note that DSPopUpNavigation makes an assumption about the location of the window's title that may not be true for nonstandard window types or in future versions of the system software. In such cases the pop-up menu will still work fine, though it may not be cosmetically correct. This is another area of the code that should be revisited when Copland becomes available.
MARK H. LINTON (mhl@hrb.com) lives in Centre Hall, Pennsylvania, with his wife Gretchen. When he isn't jetting around the globe or meeting with some high government officials as part of his job as senior engineer at HRB Systems, he can be found in his log cabin at the base of Mount Nittany playing with his Macintosh.*