September 95 - Document Synchronization and Other Human Interface Issues
Document Synchronization and Other Human Interface Issues
Mark H. Linton
One
of the things the Finder does best is maintain the illusion that an icon and
its window represent a single object. Using the routines described in this
article, your application can help maintain that illusion. You can ensure that
when the user renames an open document, the change is reflected in the document
window's title. You can also gracefully handle problems that may arise if the
document file is moved. Other improvements that make your application's
interface more consistent with the Finder's include preventing a second window
from opening when an open document's icon is double-clicked and adding a pop-up
navigation menu to the document window's title bar.
To rename a folder or file in the Finder, you click the icon name, type a new
name, and press Return. For folders, if the window is open, the change is
reflected right away in the window's title bar. But for files, if the document
is open in your application, its window may not reflect the name change. Try
this little experiment: Create a document in your application and save it.
Switch to the Finder, find your document, and change its name. What did your
application do? If it's like most applications, nothing happened: the document
window has the same name as before. Go ahead and try to use Save As to give the
file the same name you gave it in the Finder. You probably get an error
message. Now try to save the document under the original name. Do you still get
an error message? Quit your application and read on for a way out of this
frustration.
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.
The
Electronic Guide to Macintosh Human Interface Design says that applications
should "match the window title to the filename." Specifically, when a user
changes the document name in the Finder, you should update all references to
the title. The guide also refers to the
Macintosh Human Interface Guidelines,
page 143, where it says, "The document and its corresponding window name must
match at all times."
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:
- An application shouldn't poll any more often than it absolutely needs to.
Waking up an application causes a context switch, and context switches take a
significant amount of time. Forcing the system to wake up an application every
few ticks just so that it can look for file changes would be a bad idea,
especially when the application is in the background. Instead, the application
should poll only when an event has already been received -- that is, when the
application is awake. Set your WaitNextEvent sleep time appropriately, and wait
at least a second or two between "peeks." (The Finder, for instance, polls for
disk changes every five seconds or so.)
- Avoid any polling that causes disk or network access; if at all
possible, examine only information that's in RAM on the local machine. Network
access in particular can be a real drain on performance.
The sample code
follows this advice, doing everything it can to be unobtrusive. It polls for
file changes only once every second while in the foreground. In the background,
the application's WaitNextEvent sleep time is set to ten seconds, so it only
wakes up -- and thus polls -- every ten seconds if nothing else is going on. To
detect changes to files, I chose to examine the volume modification date of the
volume containing the file, since this information is always available in local
RAM, even for a shared volume. If that date changes, I look deeper to see if
the change is one I'm interested in. As you dip into the code, you'll see the
details.
I use the file reference number to track files because it survives changes in
the name and parent directory. However, this requires that the files be kept
open. If you can't keep your files open, you might want to look at John
Norstad's excellent NewsWatcher application, which uses alias records to
synchronize files. NewsWatcher is on this issue's CD; its official source can
be found at ftp://ftp.acns.nwu.edu/pub/newswatcher/.*
Friendly as it is, this polling solution is appropriate only for the current
system software; future system software versions (such as Copland, the next
generation of the Mac OS) will provide a much better way to detect changes.
Your application will be able to subscribe to notification of changes that it's
interested in. In fact, polling the current file system structures will be
unfriendly behavior under Copland, which will have demand-paged virtual memory
and a completely new file system. For this reason, the sample code is designed
to work only under System 7. You'll be able to easily retrofit the code to run
under Copland once the details of the correct way to detect file changes have
been worked out.
Every Macintosh programmer eventually comes to grips with how to keep track of
all the information associated with a document. I use a structure called a
document list and I have a set of routines that support it. The document list
reverses some common assumptions used by developers. Developers often use the
window list to track their windows and attach their document data to it, but
this limits Apple's ability to redefine the window list. My recommendation is
to create a document list (almost identical to the window list) containing the
document data and attach the windows to it. In this way, the actual structure
of the window list is not a concern. You'll find my implementation of the
document list and its supporting routines on this issue's CD.
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);
}
}
The DoSyncChecks routine (Listing 3) checks for changes to the volume that the
file is on. If the volume has been modified, DoSyncChecks returns true to
DSSyncWindowWithFile, which consequently calls the next three routines --
HandleNameChange, HandleDirectoryChange, and HandleMoveToTrash.
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;
}
After determining that the volume containing the file has been modified,
DSSyncWindowWithFile calls HandleNameChange (Listing 4). This simple routine
compares the names of the window and the file; if they're not exactly the same,
it updates the window to reflect the new filename. A minimal implementation of
document synchronization might include only this routine.
Have you been wondering where the magical file management calls that
DoSyncChecks and HandleNameChange use come from -- for example,
GetVolumeModDate and GetNameOfReferencedFile? See the file EvenMoreFiles.c on
the CD for details. This is my tribute to Jim Luther's excellent MoreFiles
collection. Whenever I need a routine that's not in the standard header, I
write it and add it to the collection. Someday we'll be up to SonOfMoreFiles
and NightOfTheLivingMoreFiles.*
Listing 4. HandleNameChange
void HandleNameChange(short aFRefNum, WindowPtr aWindow)
{
Str255 theTitle, theName;
GetWTitle(aWindow, theTitle);
GetNameOfReferencedFile(aFRefNum, theName);
if (!EqualString(theTitle, theName, true, true))
SetWTitle(aWindow, theName);
}
After checking, and possibly synchronizing, the filename, DSSyncWindowWithFile
calls HandleDirectoryChange (Listing 5) to see whether the file has been moved.
This routine starts out by comparing the old parent directory to the new parent
directory. If they're not the same, the file has been moved and the routine
stores the file's new parent directory for later use by the application. It's
possible that the file was moved to a parent for which the user doesn't have
access privileges. In that case, a later Save will fail and revert to a Save
As.
Listing 5. HandleDirectoryChange
void HandleDirectoryChange(short aFRefNum, WindowPtr aWindow)
{
long theOldParID, theNewParID;
DSGetWindowFileParID(aWindow, &theOldParID);
GetFileParID(aFRefNum, &theNewParID);
if (theOldParID != theNewParID)
DSSetWindowFileParID(aWindow, theNewParID);
}
Finally, DSSyncWindowWithFile calls HandleMoveToTrash (Listing 6) to see if the
file is in the Trash. If it is, HandleMoveToTrash gets the FSSpec corresponding
to the file reference number, which will be needed later. If the application is
running in the background, and there are unsaved changes to the document, the
routine notifies the user (with the Notification Manager) that the application
needs assistance. While waiting for the user to respond to the request for
assistance, HandleMoveToTrash handles events normally and also checks to see
whether the user has moved the document back out of the Trash. After all,
there's no sense in asking the user what to do about a file in the Trash if
it's no longer there. If the user responds to the request, or moves the file
out of the Trash while the application is still in the background,
HandleMoveToTrash removes the notification. If the file is still in the Trash
when the application becomes frontmost, an alert appears asking the user what
to do.
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.)
That's all there is to document synchronization. Now let's take a look at some
other ways you can make your application's interface more consistent with the
Finder's.
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);
A nifty feature introduced with the System 7 Finder is the pop-up menu in the
title bar that allows the user to determine the location of an open folder and
to navigate the file system without having to resort to browsing (see Figure
1). The user simply holds down the Command key and presses on the window title
to see the menu. The computer knows where your document is; it just needs a
good way to present the information. If you have Metrowerks CodeWarrior, you'll
find that it does something similar to the System 7 Finder. Your application
can provide the same interface.
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.
Consistency is one of the key principles that make using the Macintosh the
wonderful experience that it is. If your program responds to the user's actions
in the same way that the Finder does -- in particular, maintaining the illusion
that an icon and its window represent a single object -- your users can explore
your application with skills they've already acquired. The techniques presented
here show how to provide that extra measure of consistency with the Finder that
keeps the Macintosh interface clean, consistent, and seamless. They're not too
hard to implement, they're fun, and they just happen to be useful!
- Electronic Guide to Human Interface Design (Addison-Wesley, 1994). This CD
(available from Apple Developer Catalog) combines the Macintosh Human Interface Guidelines and its
companion CD, Making It Macintosh, into one easy-to-swallow capsule. Take one
every night before going to bed, and wake up with a more consistent user
interface.
- Macintosh Human Interface Guidelines, (Addison-Wesley, 1993). Available
separately from Apple Developer Catalog in book form.
- Polya, G., How to Solve It (Princeton University Press, 1945). This
book explains a logical approach to problem solving. Very simply, the approach
is: understand the problem, compare it to a related problem that has been
solved before to arrive at a plan, carry out the plan, and examine the
solution. That's what I've done with the subject of this article.
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.*