July 02 AppleScript and Cocoa
Volume Number: 18 (2002)
Issue Number: 07
Column Tag: AppleScript and Cocoa
by Bill Cheeseman, Quechee, VT
AppleScript Studio: Implementing a Document-Based Application
Saving and Retrieving Documents the Cocoa Way in AppleScript Studio
In the previous article in this series, we helped Sir Arthur Conan Doyle to start writing an AppleScript document-based application called “Doyle” using AppleScript Studio. We began by designing the graphical user interface in Interface Builder, including a Preferences window and a Doyle Usage Log window. We also scripted Doyle’s preferences system so that a user could save a preference to show or hide comments in the Usage Log. We left until this, the third article in the series, the scripting of the Usage Log itself, including saving it to disk and reading it back.
Before turning to the Usage Log, however, we must fix the bug that we found in Doyle’s preferences system at the end of the previous installment.
Updating the User Interface for Preferences—Revisited
In the previous article, we created the PreferencesController.applescript file to act as a Model-View-Controller (MVC) “Controller” script coordinating the preferences system’s “Model” (its data, managed by the PreferencesModel.applescript script) with its “View” (the graphical user interface, namely, the Preferences window and its one checkbox). In the preferences Controller script, we implemented the Choose Menu Item event handler, connecting it to menu item Application -> Preferences in MainMenu.nib. Recall that the Choose Menu Item event handler did only one thing: it loaded the Preferences.nib nib file that archives information about the Preferences window. A separate Will Open event handler then set the appearance of the checkbox in the Preferences window to match the actual preference value and made the window visible.
The fact that the Choose Menu Item event handler loads the nib file when the user chooses the Preferences menu command is both a plus and a minus. On the plus side, it enhances application performance by loading the Preferences window object “lazily;” that is, only if and when the user requests it. This speeds application launching by delaying until later the loading of the Preferences nib file, and it also makes the application’s initial memory footprint smaller. On the minus side, our first version of the Choose Menu Item event handler may load multiple copies of the nib file, and it may therefore leave multiple Preferences windows open on the screen. This happens because the nib file is loaded indiscriminately every time the user chooses the Preferences command, even if the nib file was already loaded previously. Furthermore, closing the Preferences window does not unload the nib file but only makes the window invisible, so our application could fill memory with multiple instances of the window even if they aren’t visible. (An Interface Builder setting could have been used to release the window object’s memory automatically when the window is closed, but this would come at the expense of having to reload it when the window is opened again.)
To fix our bug, we will add some statements to check whether the nib file is already loaded when the user chooses the Preferences command. We will do this by inserting another If clause in the Choose Menu Item event handler. The traditional way to do this sort of thing in a Cocoa application is to test whether the Objective-C “instance variable” representing the object is null. We will do something very similar here, declaring a new property, prefsWindow, and setting its initial value to null. We will load the nib file only if the property is still null. We will change the value of the property from a null reference to a reference to the new window the first time the window is opened. In addition, if the property is not null, we must bring the window to the front and make it visible in case it was either closed (i.e., not visible) or it was already open but hidden behind the Doyle Usage Log window.
First, declare the new prefsWindow property. The PreferencesController.applescript script already has a Properties section, so add this declaration immediately following the declaration of the prefsModelLib property we created in the previous article: property prefsWindow : null
Second, we set the value of the new property so that it refers to the new window object once the Preferences window has been opened for the first time. We already implemented the Will Open event handler in the previous article. Because this event handler brings with it a reference to the Preferences window object, add a new statement to the existing Will Open event handler, at the beginning of the if title clause, as follows: set prefsWindow to theWindow
Third, rewrite the existing Choose Menu Item event handler so that it is identical to the new version shown in Listing 1. It inserts a new If/Else clause in the if title. clause, testing whether the prefsWindow property is null. if it is, it loads the nib file as before. If not, it brings the Preferences window (which exists, but may or may not be visible) to the front and makes it key. Normally, in the classic Mac OS, a window is brought to the front within its application layer by setting its index to 1. We could therefore try set index of prefsWindow to 1 (and set visible of prefsWindow to true, if necessary). However, this doesn’t work correctly in AppleScript Studio 1.0. Instead, therefore, as a temporary workaround, we use AppleScript Studio’s Call Method handler, which enables us to call Objective-C Cocoa methods from AppleScript. In this case, we call the Cocoa makeKeyAndOrderFront: action method defined in the NSWindow class, which makes the window the key window and brings it to the front — just what we needed.
Listing 1
on choose menu item theMenuItem
-- Opens the Preferences window.
-- Connected to menu item Application -> Preferences in MainMenu.nib,
-- and potentially to other menu items.
if title of theMenuItem is “Preferences…” then
if prefsWindow is not null then
-- nib “Preferences” is already loaded
call method “makeKeyAndOrderFront:” ¬
of object prefsWindow with parameter null
else
load nib “Preferences”
end if
end if
end choose menu item
This solution works, but it may fail in a later release of AppleScript Studio. Recall from the previous article that, in the initial release of AppleScript Studio, AppleScript properties are not persistent. Here, this means that the prefsWindow property we just added to PreferencesController.applescript will revert to null every time the application is launched, and our scheme will work as intended. However, if persistence is restored to AppleScript properties in a later release, our prefsWindow property will retain a reference to the Preferences window object every time Doyle is relaunched, and that reference will undoubtedly be invalid.
To fix this potential problem in anticipation of a future release of AppleScript Studio, we will explicitly reset the value of this property to null when the user quits Doyle. Save your work in Project Builder. Then open MainMenu.nib, our main application nib file, in Interface Builder; select the File’s Owner icon in the Instances tab of the MainMenu.nib window; and select the AppleScript pane in the File’s Owner Info panel. Under the Application group, check the “should quit” event handler. The Application.applescript script is already checked, so click Edit Script. In Project Builder, find the new Should Quit event handler stub in Application.applescript, and fill it in with Listing 2. Note that the Should Quit handler returns true; this is necessary in any “should” event handler, to make sure the impending action is carried out (a return value of false amounts to a veto).
Listing 2
on should quit theObject
-- Cleans up when the application is about to quit.
-- Connected to File’s Owner in MainMenu.nib
-- Reinitialize reference to Preferences window for next launch
loadPreferencesControllerLib()
tell prefsControllerLib to reInit()
return true
end should quit
The new Should Quit event handler invokes a reInit() handler, which must now be added to the preferencesControllerLib script object in PreferencesController.applescript, in accordance with listing 3.
Listing 3
on reInit()
set prefsWindow to null
end reInit
There is a design issue in our preferences system, which we will mention only briefly. Our Preferences window is a full-fledged window, not a “panel.” Among other things, this means that the Preferences window not only becomes “key” when it is in front (meaning that it can receive keystrokes and clicks), but it also becomes “main” (meaning, among other things, that it can be printed). In your own applications, you may find it desirable to implement preferences windows as panels.
Managing the Doyle Usage Log
Now that our preferences system is bug free, we can turn to the Doyle Usage Log.
Creating or Opening a Document
We will start by figuring out how to create a new document. In our preferences system and in all of the initial AppleScript Studio example projects, this is done by resorting to AppleScript’s Read/Write commands, implemented in the Standard Additions scripting addition. Anybody who has parsed out the example projects will realize that this technique is quite limited. It fails to take advantage of the very sophisticated document-handling mechanisms built into Cocoa. This was acceptable for our preferences system, because we stored the Doyle preferences file in a fixed location under a fixed name. In the context of a normal document, however, we would have to write our own sheets and alerts using AppleScript Studio’s Display Dialog (or, in AppleScript Studio 1.1, its Display) and Display Panel commands. There is a convenient way to let Cocoa do this work for you, and the principal point of this article is to show you how.
In our preferences system, we gave the user a way to create a new document by connecting an event handler to the Preferences menu item in the Application menu. We might be tempted to start here the same way, connecting the Choose Menu Item event handler to the New menu item in the File menu. However, if you look at the File menu after compiling and running the unmodified AppleScript Document-based Application template, you will find that it is not grayed out, or disabled, as the Preferences menu item was. The reason for this is that the template has already connected the New menu item for you. You can see this by selecting the New menu item in MainMenu.nib, then selecting the Connections pane (not the AppleScript pane) in the NSMenuItem Info panel. You see that the target of this menu item is Cocoa’s built-in newDocument: action method, which creates a new, empty document for you. You may recall that you already saw this connection at work in the previous article, when you compiled and ran the unmodified template and saw that you could already use the New menu item to open an untitled document window based on the Document.nib file provided to you as part of the template.
The same is true of all the other standard menu items in the template that you will use in this series. Open, Open Recent, Save, and Save As are all connected to an appropriate built-in Cocoa action method.
Apparently, therefore, all we have to do is to capture the right event handler when one of these Cocoa action methods is invoked by the user’s choosing the corresponding menu item. We will find, once we get this working, that we can use all of the built-in document-handling mechanisms of Cocoa to read and write our documents, without making any use of AppleScript’s Read/Write commands.
To make the New menu item work, we will configure the empty Document.applescript script provided by the template as our MVC Controller script for this document, and we will implement our menu item event handlers in it. Later, we will create a separate DocumentModel.applescript file to act as the Model script that manages our document’s data. Notice that the Controller, Document.applescript, is not named “DocumentController.applescript.” You could give it this name if you wish, but it is common in Cocoa to find controller classes that don’t have the word “Controller” in their names—such as NSDocument itself, which generally acts as a document controller.
With the File’s Owner icon selected in the MainMenu.nib window in Interface Builder, select the Attributes pane in the File’s Owner Info panel. You will see that the nib file’s owner is the Document class that is provided as part of the AppleScript Document-based Application template, in the form of the Document.h header file and the Document.m source file that you see in the Classes group in the Groups & Files pane of the main Project Builder window for the Doyle application. If you click on Document.m and read it, you will see that it contains some Objective-C code implementing a few Cocoa methods apparently having to do with reading and writing document data. You, as an AppleScripter, needn’t understand this code, and you normally shouldn’t edit it unless you are also a knowledgeable Cocoa developer. Later, however, we will help you make some changes to these files to implement Cocoa’s document mechanisms for the Doyle application.
Now switch to the Connections pane of the File’s Owner Info panel, and you will see that the window outlet of the Document class is an NSWindow object named “Doyle Usage Log.” This is your window, which you created in the previous article. You are zeroing in on the spot where you can begin to write some AppleScript statements.
Select the AppleScript pane of the File’s Owner Info panel, and you will see only one event handler, Will Open, under the document category. Check its checkbox, and check Document.applescript, our controller script, then click the Edit Script button. Switch to Project Builder and select Document.applescript. You find that a Will Open event handler stub has been added, just waiting for you to edit it.
You already have a pretty good idea of what you have to do with this event handler, from your work with the preferences Controller script. You will need to update the table view in the Doyle Usage Log window to reflect either the default data of a new document or the retrieved data of an existing document, then make the window visible. Before you can do this, however, we must turn to the MVC Model script and implement our data management routines.
Managing the Document’s Data
The first step is to create a new DocumentModel.applescript script in Project Builder, and to populate it with properties and primitive handlers suitable to hold and manipulate the Doyle Usage Log data.
Before doing this, we should develop a clearer sense of how the log will be employed by our typical customer. Recall that this is an investigative tool for Sir Arthur’s characters, Holmes, Watson, and their assistants. Without trying to be overly practical about it, let’s assume that a new log will be created for each aspect of the investigation. We will therefore need to provide for multiple documents, each following the user interface design that we have already created. Investigators will be able to create a new log every time they want to start logging their work on a new lead, and they will be able to save it under a descriptive name — “Dog Didn’t Bark,” say. Each time a particular log is opened, the log file will automatically be updated to show the date and the nature of the action — and, depending on the preference settings, allow the user to view, enter, and edit comments for each entry.
In the previous article, we indicated that a log entry would be created whenever the Doyle application is launched or quit, but we have reconsidered in favor of this multi-document approach. Because the investigative team will be able to open multiple documents for various leads, noting when each document is opened will serve the same purpose as noting when the application was launched in the original specification.
To create the new script file in Project Builder. Choose File -> New. In the New File Assistant, select AppleScript File and click Next. In the New AppleScript File Assistant, name it “DocumentModel.applescript” and ensure that it will be added to the Doyle Project and will target the Doyle application, then click Finish. Type the entire contents of this new script file from Listing 4
Listing 4
(* DocumentModel.applescript *)
(* This is an MVC Model script that manages the data stored in a document. It
provides an initialization handler and get and set accessor handlers for the
entire document data store, and get and set accessor handlers for individual
data entries, as well as some utility handlers.
The value of the data store is set to empty in the initData() handler, which
is executed only when a new document is created. Otherwise, the values of all
data items are read from the document and held in an AppleScript property
in the getData() handler, and saved from the property back to the document in
the setData() handler. The AppleScript logDataStore property in this script is
associated with the dataStore instance variable declared in the Document
subclass of the NSDocument Cocoa class, by making some custom Objective-C
modifications to the default Document class provided by the AppleScript
Document-based Application template. Data values are held and managed in this
script as a single AppleScript list, and in the Document object as a single
Cocoa NSString object. It isn’t necessary to maintain dual data stores in
this manner; instead, you could write all of these handlers to deal directly
with the document’s dataStore instance variable. We maintain a separate
AppleScript list property here for clarity, and to show how it can be done
in case you want to maintain your data in multiple separate AppleScript
properties. Error checking is omitted. *)
(* Properties *)
property logDataStore : {}
(* Script Objects *)
script documentModelLib
(* Handlers *)
-- These handlers provide access to document values at the most primitive level.
-- Load this script object into another script and call these primitive handlers
-- whenever access to document values is required. If the document infrastructure
-- is later changed, only this script will require revision, so long as the
-- names, parameters, and return values of these handlers remain unchanged (i.e.,
-- each data entry is a three-item list).
on initData(theDocument)
-- Initializes data values; called only if no document file exists yet.
-- initialize instance variable dataStore of Document object to empty string
call method “setDataStore:” ¬
of object theDocument with parameter “”
-- initialize property logDataStore of this script to empty AppleScript list
set logDataStore to {}
end initData
on getData(theDocument)
-- Gets data values from Document object into script property.
-- Get tab-delimited string from instance variable dataStore of Document object
set theString to call method “dataStore” ¬
of object theDocument
-- Set property logDataStore of this script to the string as AppleScript list
set oldDelims to AppleScript’s text item delimiters
-- save old delimiters
set AppleScript’s text item delimiters to {tab}
set logDataStore to text items of theString
-- convert string to list
set AppleScript’s text item delimiters to oldDelims
-- restore old delimiters
return logDataStore as list
end getData
on setData(theDocument)
-- Sets data values in Document object from script property.
-- Get tab-delimited byte stream from property logDataStore of this script
set oldDelims to AppleScript’s text item delimiters
-- save old delimiters
set AppleScript’s text item delimiters to {tab}
set theString to (logDataStore as string)
-- convert list to string
set AppleScript’s text item delimiters to oldDelims
-- restore old delimiters
-- Set instance variable dataStore of Document object to result
call method “setDataStore:” ¬
of object theDocument with parameter theString
set modified of theDocument to true
-- so Cocoa will know it needs to be saved;
-- when it is saved, Cocoa will automatically reset modified to false
end setData
on append1DataEntry(entry, theDocument)
-- Adds a data entry to the end of the list.
set end of logDataStore to entry
setData(theDocument)
end append1DataEntry
on set1DataEntry(entryNum, entry, theDocument)
-- Replaces the data entry at entry entryNum.
set idx to (entryNum * 3) - 2 -- AppleScript lists are 1-based
tell logDataStore
set item idx to item 1 of entry
set item (idx + 1) to item 2 of entry
set item (idx + 2) to item 3 of entry
end tell
setData(theDocument)
end set1DataEntry
on remove1DataEntry(entryNum, theDocument)
-- removes the data entry at entry entryNum.
set idx to (entryNum * 3) - 2 -- AppleScript lists are 1-based
if length of logDataStore is 3 then
-- remove only entry in list
set logDataStore to {}
else if idx is equal to 1 then
-- remove first entry in list
set logDataStore to items 4 thru ¬
(length of logDataStore) of logDataStore
else if idx is equal to ¬
((length of logDataStore) - 2) then
-- remove last entry in list
set logDataStore to items 1 thru ¬
(idx - 1) of logDataStore
else
-- remove entry between first and last entries in list
set logDataStore to items 1 thru ¬
(idx - 1) of logDataStore & ¬
items (idx + 3) thru ¬
(length of logDataStore) of logDataStore
end if
setData(theDocument)
end remove1DataEntry
on get1DataEntry(entryNum, theDocument)
-- Returns current value of the data entry at entry entryNum.
set idx to (entryNum * 3) - 2 -- AppleScript lists are 1-based
getData(theDocument)
tell logDataStore to return ¬
{item idx, item (idx + 1), item (idx + 2)}
end get1DataEntry
end script
The basic strategy of the DocumentModel.applescript script is very similar to that of the PreferencesModel.applescript we developed in the previous article. An AppleScript property is defined to hold the data, and a script object containing several primitive data handlers is defined, ready to be loaded into the Document.applescript Controller script when we get around to writing it.
Here, however, the data is not a single Boolean value, as it was in the preferences Model script, but a succession of values recorded in the table view you created in the previous article: that is, an open-ended succession of log entries, each consisting of the date, the nature of the action, and some comments. An AppleScripter would be tempted to implement this data structure as a list of AppleScript records, but for simplicity’s sake we will use a simple, flat AppleScript list, instead. We know that each log entry contains three items, so we will write our Model script to treat every third list item as the beginning of a new log entry. This list will be held in an AppleScript property, logDataStore. Given your experience with the primitive data handlers in PreferencesModel.applescript last time, you will have little difficulty parsing out the function of each primitive data handler in Listing 4. Each handler simply manipulates the list value held in the logDataStore property, getting, setting, adding, and removing log entries. Each of the handlers that manipulates the value of a single log entry uses a more general handler, getData() or setData(), to obtain or update the entire list. This is everyday AppleScript list-handling code.
But wait a minute! What are those Call Method statements doing in the initData(), getData(), and setData() handlers? These, my dear colleague, are the keys to the mystery we presented to Sir Arthur in the previous article. Nowhere in the existing AppleScript Studio documentation or examples can we find a solution to the problem of using Cocoa’s built-in document-handling mechanisms. Now, we will solve the mystery ourselves and conclude the investigation. It involves writing some Objective-C methods in the Document object provided by the AppleScript Document-based Application template, and using the AppleScript Studio Call Method AppleScript command to execute them.
All of the other data handlers in DocumentModel.applescript get, set, add, and remove entries from the Doyle Usage Log by manipulating the global logDataStore AppleScript list property we added to DocumentModel.applescript. But the initData(), getData(), and setData() handlers do more than that — they also read and write the entire AppleScript list from and to the document object as a byte stream, via the Call Method statements.
Call Method is an AppleScript Studio command that enables a script to call an Objective-C Cocoa method, passing parameters to it as needed and retrieving any returned value. To take advantage of the built-in Cocoa mechanisms for reading and writing documents, we must write a small number of lines of Objective-C code. You will not need to know anything about Objective-C to do this, because we will do it for you and describe it briefly. If you choose to use this technique in your own applications, you can simply copy our code from the Document.h and Document.m files in the Classes group in the Groups & Files pane of the main project window. This is fully reusable code.
The Document class’s header and source code are shown in Listings 5 and 6, respectively.
Listing 5
// Document.h
#import
@interface Document : NSDocument
{
NSString *dataStore;
}
- (NSString *)dataStore;
- (void)setDataStore:(NSString *)data;
@end
Listing 6
// Document.m
#import “Document.h”
@implementation Document
- (NSString *)windowNibName
{
// Unchanged from AppleScript Document-based Application template
return @”Document”;
}
- (void)windowControllerDidLoadNib:
(NSWindowController *)aController
{
// Unchanged from AppleScript Document-based Application template
[super windowControllerDidLoadNib:aController];
}
- (NSData *)dataRepresentationOfType:(NSString *)aType
{
// Get dataStore NSString object and send to persistent storage as NSData object.
return [[self dataStore]
dataUsingEncoding:NSUTF8StringEncoding];
}
- (BOOL)loadDataRepresentation:
(NSData *)data ofType:(NSString *)aType
{
// Receive NSData object from persistent storage and set dataStore as NSString object.
NSString *temp = [[NSString alloc] initWithData:data
encoding:NSUTF8StringEncoding];
[self setDataStore:temp];
[temp release];
return YES;
}
// init and dealloc methods
- (id)init
{
// Allocate and initialize dataStore as an empty NSString object.
if (self = [super init]) {
dataStore = [[NSString alloc] init];
}
return self;
}
- (void)dealloc
{
// Deallocate the dataStore NSString object.
[dataStore release];
[super dealloc];
}
// dataStore accessor methods
- (NSString *)dataStore
{
// Return dataStore NSString object.
return dataStore;
}
- (void)setDataStore:(NSString *)string
{
// Set dataStore NSString object to value of data parameter.
[dataStore autorelease];
dataStore = [string retain];
}
@end
Our basic strategy is to create a single so-called “instance variable” in the Document class’s header file, which will be used to hold the logDataStore list in the form of a tab-delimited string, or byte stream. Using two Objective-C “accessor methods”—setDataStore: to set the value of the document’s instance variable and dataStore to get it—you will be able to read and write any AppleScript string or text value using the Call Method command to execute either of these two accessor methods. You won’t need to change the Objective-C code if you define some different AppleScript data types in your application, because you can convert your data between its AppleScript form and a tab-delimited string in your AppleScript files using the techniques shown in DocumentModel.applescript.
The header file simply declares the instance variable and the two accessor methods. The source file is more interesting. You already know from the previous article that the AppleScript Document-based Application template contains a very small amount of pre-written Objective-C code. In fact, this is the same pre-written code that is used to build any Cocoa document-based application. The first two of the supplied methods are of no interest to us (don’t remove them from the Document.m file, though), but the other two require slight modification to make our scheme work. In addition, of course, we must write the implementations of our two accessor methods and some Cocoa memory management code.
The supplied dataRepresentationOfType: method must return our document’s data as a byte stream, known in Cocoa circles as an NSData object. We will write this method so that it returns our instance variable, but we will never call this method ourselves. Instead, Cocoa calls it automatically when the Cocoa action methods connected to the Save and Save As menu items are invoked. By returning our instance variable as a byte stream in this method, we have, in a single line of Objective-C code, enabled our application to write its data to disk.
Similarly, the supplied loadDataRepresentation:ofType: method must assign to our instance variable the data that Cocoa reads from disk when the action method connected to the Open and Open Recent menu items is called, and return YES to indicate success. Never mind the mysterious Objective-C and Cocoa commands you see in these methods, such as alloc and release. This is Cocoa memory management code, which an AppleScripter doesn’t need to understand until deciding to learn Cocoa. The same goes for the simple init and dealloc methods that are implemented in Document.m and called automatically by Cocoa at appropriate times.
Finally, the dataStore and setDataStore: accessor methods are quite simple. Apart from the memory management code, these simply get and set the value of the instance variable. You may have noticed that the instance variable is declared as a Cocoa NSString object in Document.h. We do it this way because AppleScript Studio can move seamlessly between AppleScript strings and Cocoa NSString objects, allowing you to pass AppleScript strings to and from Cocoa by using these accessor methods in Call Method statements. Because Cocoa mandates that the dataRepresentationOfType: and loadDataRepresentation:ofType: methods must work with NSData objects, our code in those two methods converts between NSData and NSString types using Cocoa methods designed for the purpose.
The technique we use in DocumentModel.applescript to convert between the tab-delimited string held in the Cocoa Document object and the AppleScript list property we defined in DocumentModel.applescript deserves brief mention. AppleScript provides a mechanism, known as the text item delimiter (TID), to break a string into a multiple-item list, and to built a multi-item list back into a string, very rapidly. We therefore set AppleScript’s text item delimiter temporarily to an ASCII horizontal tab character, then either get the multiple text items of a tab-delimited string retrieved from the Document object, or get the string by converting the AppleScript property using AppleScript’s As String coercion. We save AppleScript’s existing text item delimiter and restore it at the end of the conversion, to maintain a known baseline in case other routines rely on the text item delimiter remaining unchanged (this step is not mandatory but is very commonly used in AppleScript).
With about a dozen lines of Objective-C code and some AppleScript support, we have created a reusable custom Document class that will allow any AppleScript Studio application to save information to disk and retrieve it from disk using Cocoa’s built-in mechanisms and the existing menu items implemented in the AppleScript Document-based Application template. All of the disk navigation sheets for selecting a name and location under which to save the data are provided automatically by Cocoa, along with Aqua-compliant sheets for opening a file you have saved. In addition, for free, you get alerts for handling name collisions and duplicates, for dealing with multiple unsaved files when the user quits the application, and so on. You don’t have to spend any time dealing with the AppleScript Read/Write commands or writing your own sheets and alerts using AppleScript Studio’s Display Panel and similar commands. At the small cost of getting comfortable with a little Objective-C code, you have avoided a significant amount of work and created a much more powerful and standards-compliant application.
Updating the Document’s User Interface
With our primitive data management handlers and their Cocoa support routines in place, we can now return to Document.applescript to coordinate a Doyle document’s data with the user interface. Again, the strategy is similar to what we did with the preferences Controller script. We declare a property to hold the script object defined in DocumentModel.applescript and a loadDocumentModelLib() handler to load it, and a similar property and handler to get at the preference value managed by PreferencesModel.applescript. We also define a script object containing high-level counterparts of the primitive document data handlers. Although these will only be used within Document.applescript itself and therefore don’t have to be wrapped in a script object, we place them in a script object in case we might want to load them into some other script as a future enhancement. Finally, we implement some utility handlers and event handlers to deal with the user interface when the user creates new documents, opens existing documents, and saves changes to disk. See Listing 7.
Listing 7
(* Document.applescript *)
(* This is an MVC Controller script containing handlers to coordinate data
values in DocumentModel.applescript with the user interface in the main
document window. It does this for the window’s table view by manipulating
the table view’s associated data source object, which is connected to the
window in Interface Builder. The data source object is manipulated here,
in the document’s Controller script, because the data source is a
view-related object, not to be confused with the document’s data store
instance variable, which is manipulated in DocumentModel. applescript, an
MVC Model script. *)
(* Properties *)
property logModelLib : null
property prefsModelLib : null
property windowList : {}
-- list of records associating documents with their windows by ID
(* Script Objects *)
script documentControllerLib
-- These handlers are placed in a script object in case future
-- enhancement of the application requires that they be loaded
-- into another script.
(* Handlers *)
-- These handlers manipulate a document’s data using plain English
-- terminology, by loading and calling the primitive data handlers
-- in DocumentModel.applescript. If the document infrastructure in
-- DocumentModel.applescript is later changed, this script will not
-- require revision, so long as the names, parameters, and return
-- values of the primitive data handlers remain unchanged (i.e.,
-- each log entry is a three-item list).
on initLog(theDocument)
loadDocumentModelLib()
tell logModelLib to initData(theDocument)
end initLog
on getLog(theDocument)
loadDocumentModelLib()
tell logModelLib to return getData(theDocument)
end getLog
on setLog(theDocument)
loadDocumentModelLib()
tell logModelLib to setData(theDocument)
end setLog
on addLogEntryAtEnd(entry, theDocument)
loadDocumentModelLib()
tell logModelLib to ¬
append1DataEntry(entry, theDocument)
end addLogEntryAtEnd
on replaceLogEntryAt(idx, entry, theDocument)
loadDocumentModelLib()
tell logModelLib to ¬
set1DataEntry(idx, entry, theDocument)
end replaceLogEntryAt
on removeLogEntryAt(idx, theDocument)
loadDocumentModelLib()
tell logModelLib to ¬
remove1DataEntry(idx, theDocument)
end removeLogEntryAt
on getLogEntryAt(idx, theDocument)
loadDocumentModelLib()
tell logModelLib to ¬
return get1DataEntry(idx, theDocument)
end getLogEntryAt
end script
(* Handlers *)
on loadDocumentModelLib()
-- Loads documentModelLib from DocumentModel.applescript,
-- if not already loaded.
if class of logModelLib is not script then
load script POSIX file ¬
(path for main bundle script ¬
“DocumentModel” extension “scpt”)
set logModelLib to documentModelLib of result
end if
end loadDocumentModelLib
on loadPreferencesModelLib()
-- Loads PreferencesModelLib from PreferencesModel.applescript,
-- if not already loaded.
if class of prefsModelLib is not script then
load script POSIX file (path for main bundle ¬
script “PreferencesModel” extension “scpt”)
set prefsModelLib to preferencesModelLib of result
end if
end loadPreferencesModelLib
on initDataSource(theWindow)
-- Creates data columns in the data source
-- of the table view loaded from Document.nib.
tell data source of table view “Log” ¬
of scroll view “Log” of theWindow
make new data column at end of data columns ¬
with properties {name:”date”}
make new data column at end of data columns ¬
with properties {name:”action”}
make new data column at end of data columns ¬
with properties {name:”comments”}
end tell
end initDataSource
on updateDataSourceOnOpen(theWindow)
-- Updates the data source of the table view loaded from
-- Document.nib when theWindow opens (whether on a new or
-- existing document). This automatically updates the
-- contents of the table view on screen.
set theDocument to getDocumentForWindow(theWindow)
tell documentControllerLib to getLog(theDocument)
set theLog to result
repeat with idx from 1 to length of theLog by 3
-- The log has at least one entry (3 list items),
-- because we set up the first entry in the Will
-- Open event handler when the document opened.
set entryNum to (idx div 3) + 1
-- AppleScript lists are 1-based
tell documentControllerLib to ¬
getLogEntryAt(entryNum, theDocument)
set theEntry to result
tell data source of table view “Log” ¬
of scroll view “Log” of theWindow
set theRow to make new data row at end of data rows
set contents of data cell “date” of theRow to ¬
item 1 of theEntry
set contents of data cell “action” of theRow to ¬
item 2 of theEntry
set contents of data cell “comments” of theRow to ¬
item 3 of theEntry
end tell
end repeat
end updateDataSourceOnOpen
on getDocumentForWindow(theWindow)
-- Returns the document object associated with theWindow in windowList.
repeat with idx from 1 to length of windowList
if winID of item idx of windowList is equal to ¬
id of theWindow then
return document id (docID of item idx of windowList)
end if
end repeat
return null
-- no associated window found (shouldn’t get here)
end getDocumentForWindow
on removeFromWindowList(theWindow)
-- Removes the window from windowList
repeat with idx from length of windowList to 1 by -1
-- always remove from list backwards for correct indices
if winID of item idx of windowList is equal to ¬
id of theWindow then
if length of windowList is 1 then
-- remove only record in list
set windowList to {}
else if idx is equal to 1 then
-- remove first record in list
set windowList to rest of windowList
else if idx is equal to length of windowList then
-- remove last record in list
set windowList to items 1 thru ¬
((length of windowList) - 1) of windowList
else
-- remove record between first and last records in list
set windowList to items 1 thru ¬
(idx - 1) of windowList & ¬
items (idx + 1) thru (length of windowList) ¬
of windowList
end if
end if
end repeat
end removeFromWindowList
(* Event Handlers *)
on will open theObject
-- Initializes a new document or gets data stored in an existing
-- document, then appends a new automatic log entry, then updates
-- the data source to set the visual state of the window to match,
-- and makes the window visible. Also adds document and window to
-- windowList property for later lookup. Connected to the document
-- (File’s Owner) and window objects in Document.nib.
if class of theObject is document then
-- The document object opens before the window object opens.
-- The document’s nib file is automatically loaded by Cocoa.
get windows whose visible is true
if result is {} then set windowList to {}
set end of windowList to ¬
{winID:0, docID:id of theObject}
if file name of theObject exists then
-- Standard technique to determine whether
-- existing document is being opened.
tell documentControllerLib to getLog(theObject)
set theActionItem to “Open”
else
-- New document is being created.
tell documentControllerLib to initLog(theObject)
set theActionItem to “New”
end if
get current date
get date string of result & space & ¬
time string of result
set theEntry to {result, theActionItem, “”}
tell documentControllerLib to ¬
addLogEntryAtEnd(theEntry, theObject)
else if class of theObject is window and ¬
name of theObject is “Log” then
-- The window object opens after the document object opens.
set winID of last item of windowList to id of theObject
initDataSource(theObject)
updateDataSourceOnOpen(theObject)
-- Hide Comments column according to preference
loadPreferencesModelLib()
tell prefsModelLib to getShowCommentsPreference()
if result is false then
set theCol to table column “comments” of ¬
table view “Log” of scroll view “Log” of theObject
set editable of theCol to false
set width of theCol to 0
end if
set enabled of button “Delete” of theObject to false
set visible of theObject to true
end if
end will open
on will close theObject
-- Removes window from windowList property when window is
-- closed. Connected to the window object in Document.nib.
if class of theObject is window and ¬
name of theObject is “Log” then
removeFromWindowList(theObject)
end if
end will close
on should selection change theObject
-- Commits edited Comments when user clicks or tabs out of
-- cell or presses Enter or Return. Connected to the table
-- view object in Document.nib.
if (class of theObject is table view) and ¬
(name of theObject is “Log”) then
get window of theObject
set theDocument to getDocumentForWindow(result)
set rowNum to selected row of theObject
-- row number before selection changes
if rowNum > 0 then
-- some row is selected, and possibly is being edited
tell documentControllerLib to ¬
getLogEntryAt(rowNum, theDocument)
set theEntry to result
-- old cell value
get contents of data cell “comments” of ¬
data row rowNum of data source of theObject
-- new cell value
if theEntry is not equal to result then
-- replace only if old and new cell values are different
set item 3 of theEntry to result
tell documentControllerLib to ¬
replaceLogEntryAt(rowNum, theEntry, theDocument)
end if
end if
end if
return true
end should selection change
on selection changed theObject
-- Changes enabled state of Delete button when row of table
-- view is selected or deselected. Connected to the table
-- view object in Document.nib.
if class of theObject is table view and ¬
name of theObject is “Log” then
if selected row of theObject is 0 then
-- no row is selected
set enabled of button “Delete” of window of ¬
theObject to false
else
-- a row is selected
set enabled of button “Delete” of window of ¬
theObject to true
end if
end if
end selection changed
on clicked theObject
-- Deletes selected row when Delete button is clicked.
-- Connected to Delete button object in Document.nib.
if theObject is equal to button “Delete” of ¬
main window then
set theTableView to table view “Log” of ¬
scroll view “Log” of main window
set rowNum to selected row of theTableView
if rowNum > 0 then
-- some row is selected
delete data row rowNum of data source of theTableView
my getDocumentForWindow(window of theTableView)
tell documentControllerLib to ¬
removeLogEntryAt(rowNum, result)
end if
end if
end clicked
Most of the handlers in Document.applescript deal with the table view that we designed in Interface Builder in the previous article. Because table views are covered quite thoroughly in the AppleScript Studio documentation and examples, we will not explain all the details here.
In summary, we use Interface Builder to connect an event handler that responds to the user’s opening a document and opening a window; namely, the Will Open event handler. When the user chooses the New or Open menu item or a document from the Open Recent menu item, a Will Open event announces the creation of the document object, first, then a second Will Open event announces the opening of the document’s associated window object. We use If tests to distinguish between these two events, taking care of creating the data for a new automatic log entry when the document object is created, in accordance with our application specification, and updating the data source object to display this data when the window object opens. The data source object, which controls the appearance of data in the table view, is adequately covered in the documentation and example projects. The final step, of course, is to make the window visible—Cocoa loads the nib file automatically in a document-based application.
We use Interface Builder to connect the Will Close event handler for the sole purpose of removing the window’s record from a window list that we maintain to associate each document with its window. We set up a new entry in this list in the Will Open handler, getting the ID of each document and each window as they are opened. The getDocumentForWindow() utility handler allows us to get the document that is associated with any given document window using this list. The removeFromWindowList() utility handler called from the Will Close event handler simply removes the closing window’s record from the list, using standard AppleScript code for removing an item from a list.
The next step is very significant. We use Interface Builder to connect the Should Selection Change event handler to the table view in the window. This is critical, as it allows us to write an AppleScript Studio application in which a user can edit individual items of a table view in place. The AppleScript Studio example projects instead use the awkward technique of requiring a user to type information into a series of separate text fields, then setting each cell in the table view to a value shown in the corresponding text field by the use of AppleScript statements. Cocoa allows you to edit cells of a table view directly, in place, by double-clicking on a cell to select it for editing, then typing, then clicking or tabbing out of it or pressing Enter or Return to commit the new value. The importance of the Should Selection Change event handler is that it captures the very user actions which, under the Aqua Human Interface Guidelines, signal the user’s intent to commit the data in an edited cell. This enables us to grab the new data in a cell of the table the moment the user “commits” the edit; that is, when the user clicks or tabs out of the Comments field or presses Return or Enter. If the value in the Comments field differs from the value in the data source object, we call our high-level handler to replace that log entry in DocumentModel.applescript. We return true, of course, in order to avoid vetoing the pending action in this “should” event handler.
Finally, we use the Should Selection Change and Selection Changed event handlers to find out whether a row in the table is being selected or deselected, and to enable or disable our Delete button accordingly. Notice that it was disabled in the Will Open handler, because we do not select a row automatically when a window first opens. The Clicked event handler takes care of deleting a row when the user clicks the Delete button.
There is one final element of our document-handling routines, necessitated by a bug in the first release of AppleScript Studio. Although all of the routines described above work when the user sets out to create a new document or to open an existing document, they crash if the application is designed to open a new, empty document on launch—as every proper Mac OS X application is supposed to do. We have not succeeded in finding a workaround, so we will disable the creation of a new, empty document when Doyle is launched. This is easily done by adding the following event handler at the end of the Application.applescript file that we wrote in the previous article:
Listing 8
on should open untitled theObject
-- Suppresses opening an empty document at launch
return false
end should open untitled
This bug is fixed in AppleScript Studio 1.1, so the handler can be removed when Doyle is built for that environment. Either disconnect the handler in MainMenu.nib, or revise it to return true.
At this point, we have implemented almost all of our application specification. The Doyle application’s New, Open, Open Recent, Save, and Save As menu items work as they should to implement the Cocoa document-handling facilities. The only remaining task is to honor the preference setting. We will turn to that in a moment.
We haven’t yet described all of the Interface Builder Info Panel Attribute settings for the Document.nib file. We will summarize them here. The Log window object’s Resize setting should be turned off, because we haven’t implemented any of the Interface Builder techniques for stretching and shrinking user controls to match the window size. The table view object’s Allows Empty Selection setting should be turned on, but the Allows Multiple Selection and Allows Column Selection settings should be turned off. Also, Vertical Scrollbars should be turned on and Horizontal Scrollbars should be turned off. The Date and Action table column objects’ Editable Option should be turned off, while the Comments table column object’s Editable Option should be turned on. Don’t forget to provide names for all the objects that are controlled via AppleScript, both in the Attributes Pane and the AppleScript Pane; these names should match those used in the relevant handlers in the scripts. The name of an object in the Attributes and AppleScript panes should be the same. Don’t forget to give the scroll view (click once to select it) the same name as the table view (double-click to select it).
There are a couple of points we haven’t covered. For one thing, the Revert menu item doesn’t work right. We will leave it to you to fix if you can. For another, we haven’t implemented any ability to add new log entries to the table once a document window is open. This is a consequence of our application specification, which creates a new log entry automatically when a new window is opened and not otherwise. Adding an “Add” button alongside the existing “Delete” button is an easy matter, already covered by one of the AppleScript Studio example projects.
Honoring the Preferences Setting
Our last task is to ensure that the Comments column is not visible in the event the user has deselected the Show Comments preference we created in the previous article. We do this with a block of AppleScript code in the Will Open handler that executes whenever a window opens. We load PreferencesModel.applescript, if it is not already loaded, and get the setting of the preference item. If it is false (i.e., unchecked), we make the Comments column of the window uneditable and, more to the point, we set its width to zero to effectively hide it from view. This is not a particularly attractive solution, because the column header is truncated imperfectly, but it is the only way we have found to hide a table view column.
Notice that any change to the preference setting takes effect only with respect to windows that are opened thereafter. Windows that are already open do not change. It is certainly possible to write the application so that the Comments column will instantly disappear or reappear the moment the setting is changed, but this would involve a lot of additional code that isn’t needed for this article.
Conclusion
In this series of articles, you have been exposed to some relatively complex AppleScript code illustrating how to create a Cocoa application using AppleScript. We have focused on a modest—and somewhat fanciful—application specification in order to keep the articles shorter than they would otherwise have been, but we have been able to cover many of the techniques used in AppleScript Studio and a representative sampling of its capabilities. AppleScript Studio’s terminology dictionary is extensive, and you can use it to implement just about all of the user interface widgets and functionality that are seen in Mac OS X applications.
The most important point of these articles is to show that it is possible, with only a little custom Objective-C code, to make use of built-in cocoa features that have no direct counterpart in AppleScript. While AppleScript does implement the Read/Write commands that allow a scripter to store and retrieve data, these commands are very simple and do not include any of the user interface elements that go along with storing and retrieving data in a fully Aqua-compliant application. Not only do you gain the benefit of Cocoa’s superb user-interface functionality by doing things the Cocoa way, instead, but it’s actually easier than doing things the traditional AppleScript way.
Compare the Read/Write technique we used to store and retrieve the preferences settings in the previous article with the Cocoa techniques we used to store and retrieve document data in this article. Then imagine how much more AppleScript code you would have had to write if you had wanted to let the user store the preference setting in any folder under any name. You would have had to write all the sheets and alerts that deal with file navigation and name collisions yourself. But, using the techniques taught here for the Doyle Usage Log, you never have to go to that trouble for any document.
Similar savings can undoubtedly be achieved in other aspects of AppleScript Studio application development. One has already been released by Apple informally on the AppleScript-Studio mailing list and appears as a built-in feature in AppleScript Studio 1.1, allowing the saving and retrieval of preference settings using the built in NSUserDefaults facility in Cocoa instead of the Read/Write commands technique we used in the previous article. Others are sure to follow.
Bill Cheeseman is a retired lawyer now making his living as a Macintosh developer and loving every minute of it. He is uniquely qualified to write about AppleScript Studio, having served as webmaster of The AppleScript Sourcebook (www.AppleScriptSourcebook.com) for many years and having also written a Cocoa tutorial, Vermont Recipes – A Cocoa Cookbook (www.stepwise.com/Articles/VermontRecipes/).