Sunday, February 19, 2006

This weekend's project goal was to design and implement an architecture to support file pickers (i.e., Open and Save As... dialogs).

Unlike the widgets implemented to date, file pickers are not specified in XML markup, and they are not supported directly by the layout engine. Instead, file pickers come and go as the application needs them. Most of the time, this will be when the user selects the Open, Save, or Save As menu items in the File menu, but there is no strong reason to restrict an application from using them at other times.

In Windows .NET, file pickers are implemented by two subclasses of FileDialog: OpenFileDialog, and SaveFileDialog. In Gtk+, there is a single class, GtkFileSelection, which can be tailored to act as a Save As dialog or an Open dialog. In Cocoa (MacOS X), the class NSOpenPanel implements Open dialogs, while NSSavePanel is used to implement Save As... dialogs.

The following screen shots illustrate Save As... as it appears on Windows, Gtk+, and MacOS X Cocoa. The screen shots are directly from my layout engine, and were obtained by running the sample application and selecting File->Save As...







Let's take a look at how the application uses the Save As... dialog, and then we'll go on to describe the implementation in some detail.

In markup, the Save As... menu item is described as follows:

<menubar>
<menu id="file-menu" label="File" shortcut="f">
<menuitem class="separator"/>
<menuitem label="&Open" shortcut="o" onclick="MenuFileOpen();"/>
<menuitem class="separator"/>
<menuitem label="&Close" shortcut="w" class="close" onclick="MenuFileClose();"/>
<menuitem label= "&Save" shortcut="s" class="save" onclick="MenuFileSave();"/>
<menuitem label= "Save &As..." shortcut="S" class="saveAs" onclick="MenuFileSaveAs();"/>
...

The above markup defines a menuitem in the File menu with the label Save As..., and sets its onclick attribute to the string "MenuFileSaveAs();". When the user selects the Save As... menu item, control will pass into the user-supplied JavaScript function MenuFileSaveAs(), which is implemented as follows:

function MenuFileSaveAs()
{
var paths = filepicker.saveAsPicker();
dump("Inside of MenuFileOpen " + paths + "\n");
}

The layout engine now supports a JavaScript object named filepicker. This object (at the moment) supports two simple functions: saveAsPicker() and openPicker(). saveAsPicker() is called to put up a Save As... dialog, and openPicker() is called to display an Open dialog, which is processed modally. Once the user dismisses the modal picker, both of these functions return a JavaScript string which contains a comma-separated list of paths selected by the user. The JavaScript code can either explode this CSV, and process it, or it can pass it down to a native (C++) component for handling.

The filepicker object is at the core of the implementation of both the Open and Save As... functionality. It interacts with the platform factory objects to create a concrete, platform-specific implementation instance of a Save As... (or Open). The concrete implementation then goes on to post the dialog, await its dismissal, and then package up the selected paths are return them back to the JavaScript runtime.

The filepicker class is instantiated as a singleton early in the lifetime of the layout engine. The job of this class is simple: create a JavaScript object named filepicker that implements both openPicker() and saveAsPicker(), register this object with the JavaScript runtime, and implement the openPicker() and saveAsPicker() function calls. To create and register the filepicker JavaScript object, the layout engine creates an instance of the filepicker C++ object, and then calls CreateJSObject():

static JSFunctionSpec filepicker_functions[] = {
{"openPicker", DoOpenPicker, 1},
{"saveAsPicker", DoSaveAsPicker, 1},
{0}
};

PRStatus
FilePicker::CreateJSObject()
{
JSEngine *js = JSEngine::GetJSEngine();

if (!js)
return PR_FAILURE;

JSContext *ctx = js->GetContext();
JSObject *obj = js->GetGlobalObject();

JSClass filepicker_class = {
"filepicker", JSCLASS_NEW_RESOLVE,
JS_PropertyStub, JS_PropertyStub,
JS_PropertyStub, JS_PropertyStub,
JS_EnumerateStub, JS_ResolveStub,
JS_ConvertStub, JS_FinalizeStub
};

m_jsObj = JS_DefineObject(ctx, obj, "filepicker",
&filepicker_class, NULL, 0);

JS_DefineFunctions(ctx, m_jsObj, filepicker_functions);

return PR_SUCCESS;
}

The object itself is defined by calling JS_DefineObject(), and passing a data structure that defines the class and, in particular, the name by which it is made available to JavaScript functions. The functions that can be called off of this object are defined by calling JS_DefineFunctions(), which takes yet another data structure (filepicker_functions in the above listing) that maps function names to pointers to functions that implement them.

So, what happens when the JavaScript Save As... menuitem handler function calls saveAsPicker()? The JavaScript engine will, because of the mapping we specified, will call DoSaveAsPicker(). Let's now take a look at this function:

JS_STATIC_DLL_CALLBACK(JSBool)
DoSaveAsPicker(JSContext *cx, JSObject *obj, uintN argc,
jsval *argv, jsval *rval)
{
*rval = STRING_TO_JSVAL("");
SaveAsPickerImpl *saveAsPicker;
WidgetFactory *factory = GetWidgetFactory();
if (!factory)
return JS_FALSE;

saveAsPicker = factory->MakeSaveAsPicker();
if (saveAsPicker && saveAsPicker->Create() == PR_SUCCESS) {
string files;
if (saveAsPicker->GetFile("", files) == PR_SUCCESS) {
JSString *str = ::JS_NewStringCopyN(cx, files.c_str(),
files.size() + 1);
*rval = STRING_TO_JSVAL(str);
}
}

JSBool ret = JS_TRUE;

return ret;
}

The first major task of this function is to get an instance of the WidgetFactory. The WidgetFactory is the same that is used to get concrete implementations of all the widgets supported by the layout engine. We then ask it to make an instance of an abstract Save As... picker object by calling MakeSaveAsPicker(). Now we have an object of type SaveAsPickerImpl, and with is object we can create (by calling Create()) and display (by calling GetFile()) a platform-specific Save As... dialog. The concrete classes GtkSaveAsPickerImpl, CocoaSaveAsPickerImpl, and WindowsSaveAsPickerImpl all derive from SaveAsPickerImpl, and implement its interface, which is:

#if !defined(__SAVEASPICKERIMPL_H__)
#define __SAVEASPICKERIMPL_H__

// abstract class for saveas picker implementations

#include "prtypes.h"

#include <string>

using namespace std;

class SaveAsPickerImpl
{
public:
SaveAsPickerImpl() {};
virtual ~SaveAsPickerImpl() {};
virtual PRStatus Create() = 0;
virtual PRStatus GetFile(const string &path, string &files) = 0;
};

#endif

When the abstract SaveAsPickerImpl call to GetFile() returns, we simply convert the STL string object that was passed as a reference to GetFile() to a JavaScript string, assign it to the rval argument, and then return.

The concrete implementations of Create() and GetFile() were largely straightforward. All platforms support the ability to post such a dialog modally, though in Gtk+ it requires the program to enter a new main loop (more on that later). On Windows, getting the managed .NET string returned by the FileSaveDialog object converted to a C-based string was a pain, here is the code I never would have guessed, but eventually found after doing some googles and, as is usually the case, reading more wrong answers than correct ones. The nasty line is in bold in the following listing:

PRStatus WindowsSaveAsPickerImpl::Create(const string &title)
{
m_saveaspicker = __gc new SaveFileDialog();
if (m_saveaspicker) {
m_saveaspicker->Title = title.c_str();
m_saveaspicker->Multiselect = true;
return PR_SUCCESS;
}
return PR_FAILURE;
}

PRStatus WindowsSaveAsPickerImpl::GetFile(const string &path, string &files)
{
files = ""; // assume the user selects nothing

// show the dialog modally

if (m_saveaspicker && m_saveaspicker->ShowDialog() == DialogResult::OK) {

// get the filenames selected, if any

String *filesret[] = m_saveaspicker->FileNames;

// take the result and make a CSV list of the selected filenames

bool first = true;
for (int i = 0; i < m_saveaspicker->FileNames->Length; i++) {
int len = filesret[i]->Length;
if (len) {
if (!first)
files += ",";
first = false;
char *buf;
buf = reinterpret_cast<char *>(Runtime::InteropServices::Marshal::StringToHGlobalAnsi(filesret[i]).ToPointer());
files += buf;
}
}
}
return PR_SUCCESS;
}

The above code should be fairly easy to follow. Create() calls __gc new FileSaveDialog() to create an instance of the .NET FileSaveDialog() class. GetFile() posts the dialog modally, gets the selected filenames, and returns the answer.

The Cocoa implementation is nearly the same, performing the same functionality (and providing an infinitely easier way to convert a string from a native representation (in this case NSString) to C:

PRStatus CocoaSaveAsPickerImpl::Create(const string &title)
{
m_saveaspicker = [NSSavePanel savePanel];
if (m_saveaspicker)
return PR_SUCCESS;
return PR_FAILURE;
}

PRStatus CocoaSaveAsPickerImpl::GetFile(const string &path, string &files)
{
files = "";

// Set the options for how the get file dialog will appear

[m_saveaspicker setCanSelectHiddenExtension:YES];

// set up default directory

int result = [m_saveaspicker runModalForDirectory:nil file:nil];

if (result != NSFileHandlingPanelCancelButton) {

// append each chosen file to our list, creating a CSV

bool first = true;
for (unsigned int i = 0; i < [[m_saveaspicker filenames] count]; i ++) {
NSString *path = [[m_saveaspicker filenames] objectAtIndex:i];
if (path) {
if (!first)
files += ",";
first = false;
char *buffer;
buffer = (char *) malloc([path length] + 1);
if (buffer) {
[path getCString: buffer];
files += buffer;
free(buffer);
}
}
}
}
return PR_SUCCESS;
}

Finally, let's take a look at what was needed to implement the above under Gtk+. As I mentioned earlier, to go modal in Gtk+, one must introduce a main loop at the desired location. Main loops in Gtk+ can be embedded -- to exit the innermost main loop, one calls gtk_main_quit(). So, to support modality, we will enter a main loop, and when the user clicks either the Ok or Cancel buttons, we will exit the embedded loop to return control to the main application loop. The other major change is that instead of calling a modal function and retrieving the result as was done for Cocoa and .NET, Gtk+ forces us to register a callback function that will be invoked when the user clicks the Ok button in the GtkFileSelection dialog. This callback will then retrieve the selection. Two other signal handler callbacks are registered with the file selection widget, instructing the widget to destroy the dialog when either Ok or Cancel is clicked. So, in the end, if Ok is clicked, we will call two callbacks, one to retrieve the path selected by the user from the file selection widget, and another which destroys the window. Only the callback that destroys the window will be invoked if Cancel is clicked. Here is the code for the Create() function, which creates the GtkFileSelection dialog, and the callbacks that handle Ok and Cancel button presses:

extern "C" {

gint HandleSaveAsPickerOkThunk(GtkWidget *widget, gpointer callbackData)
{
GtkSaveAsPickerImpl *pPickerImpl = (GtkSaveAsPickerImpl *) callbackData;
if (pPickerImpl) {
GtkWidget *w = pPickerImpl->GetImpl();
if (w) {
pPickerImpl->SetFilename(
gtk_file_selection_get_filename(GTK_FILE_SELECTION(w)));
}
gtk_main_quit();
}
return TRUE;
}

gint HandleSaveAsPickerCancelThunk(GtkWidget *widget, gpointer callbackData)
{
gtk_main_quit();
return TRUE;
}

} // extern "C"

PRStatus GtkSaveAsPickerImpl::Create(const string &title)
{
m_saveaspicker = gtk_file_selection_new(const_cast<char *>(title.c_str()));
if (m_saveaspicker) {

gtk_signal_connect(GTK_OBJECT(
GTK_FILE_SELECTION(m_saveaspicker)->ok_button),
"clicked", GTK_SIGNAL_FUNC(HandleSaveAsPickerOkThunk), this);

gtk_signal_connect_object(GTK_OBJECT(
GTK_FILE_SELECTION(m_saveaspicker)->ok_button),
"clicked", GTK_SIGNAL_FUNC(gtk_widget_destroy),
GTK_OBJECT(m_saveaspicker));

gtk_signal_connect(GTK_OBJECT(
GTK_FILE_SELECTION(m_saveaspicker)->cancel_button),
"clicked", GTK_SIGNAL_FUNC(HandleSaveAsPickerCancelThunk), this);

gtk_signal_connect_object(GTK_OBJECT(
GTK_FILE_SELECTION(m_saveaspicker)->cancel_button),
"clicked", GTK_SIGNAL_FUNC(gtk_widget_destroy),
GTK_OBJECT(m_saveaspicker));

return PR_SUCCESS;
}

The function GetFile() shows the GtkFileSelection dialog and then calls gtk_main() to cause it to display modally. When either callback calls gtk_main_quit(), gtk_main() returns, and the member variable set by HandleSaveAsPickerOkThunk() will contain the selections made by the user. GetFile() simply returns the value of the member variable to the caller by assigning to the STL string reference argument passed to GetFile(). Here is the code for GetFile():

PRStatus GtkOpenPickerImpl::GetFile(const string &path, string &files)
{
files = "";
if (m_openpicker) {
gtk_widget_show(m_openpicker);
gtk_main();
}
if (GetOkPressed() == true) {
files = m_filename;
}
}

Projects on deck for next weekend include developing basic support for message boxes, and then adding some interfaces to widget to allow widgets (menuitems in particular) to be made inactive or disabled from JavaScript. I also need to revisit printing, but after some conversations with engineers at RedHat, I will put that off for a few months since they have promised me they are planning to release by May a Gtk+ widget that supports (gasp) CUPS-based printing. I say it's about time!