Cross-Toolkit Software



Developing cross-platform software is easy: use cross-platform libraries, and you're set! When GUIs are involved, however, things get a little trickier. Different platforms tend to have different ways of doing things, GUI-wise. There are libraries that attempt to abstract away these differences to allow developers to build "write once, deploy everywhere" GUI-enabled applications (Qt, wxWidgets, GTK+, etc.), but they always seem to fall short when it comes to the "nativeness" of the look and feel, especially for Mac OS X. To have a cross-platform GUI-enabled application that looks and feels native on all platforms, one obvious solution is to have a cross-toolkit design.

By "cross-toolkit", I mean an application that uses more than one toolkit for the different platforms it supports. For example, my own applications use two toolkits: Cocoa (through PyObjC) under Mac OS X, and Qt (through PyQt) under Windows.

But going cross-toolkit, although it gives your software nativeness, brings its own share of problems. When you create a cross-toolkit software, you have to decide what code belongs to the core, and what code belongs to the toolkit-specific code. If you put too much code in the core, you end up re-writing your own little toolkit wrapper. Then, you realize you abstracted away too much when a platform's specificity forces you to either hack around your core design or rethink that design. If you don't give your core enough responsibilities, you end up with logic duplication among your different toolkit-specific codebases.

In this article, I'm going to take a look at three softwares: A single-toolkit-cross-platform software (OpenOffice), a cross-toolkit software with logic duplication in it (Transmission), and then my own attempt a creating a great cross-toolkit software (moneyGuru).

Case Study #1: Open Office

I'm commenting the 3.1.1 release.

Open Office is a single-toolkit application in all its Java-esque glory. I've only briefly looked at its gargantuan codebase (my doctor said it's hazardous for my health to read Java code). Its toolkit seems to be a re-implementation of AWT. There's probably very little logic duplication in that codebase. However, opening Open Office for the first time on OS X brings you this very foreign-looking wizard window (a concept already foreign to OS X):

Open Office welcome wizard

If you're an OS X user, you know what I mean when I say this looks foreign (it's mostly because of the "Steps" subsection though). And it doesn't get better when you're finished going through the "Next" clicks:

Open Office main window

But the worst is usually not the look, it's the feel. Standard keyboard binding not working (I'm addicted to Cmd-E and Cmd-G), Cmd-Y redos (C'mon, it's Cmd-Shift-Z dammit!), foreign corrector, foreign address book, all this stuff. No wonder why the whole project was forked into NeoOffice, a single-platform project for OS X. If you care enough to listen to them, most OS X users refer to applications with bad look and feel as "Java applications", even if they're not written in Java (how could they know? If they knew their beloved Cyberduck was written in Java... teehee!). That's what you get with "Code once, deploy everywhere".

Seriously, forget about this motto. It's time to go cross-toolkit (or to stay single-platform).

Case Study #2: Transmission

I'm commenting the 1.76 release.

Transmission is a pretty good example of a well engineered, widely used, cross-toolkit codebase. It has native Cocoa, Qt, Gtk, command-line and web clients, all of this around a C core library.

I have a very shallow knowledge of Transmission codebase and design decisions, but it seems to me that one (very minor, given the nature of the application) problem with its design is that the "main control" of the application is entirely given to the client code. The cross-platform code is more of a library than anything else.

The main advantage of this approach is that the client code has a lot of flexibility as to how it presents the core library's functionalities to the user (the command line and web clients are good examples of this).

The main downside of this approach, however, is logic duplication. This duplication not only implies more work to build clients, but also more work to maintain them and and increased likeliness of bugs. Moreover, since we're dealing with entirely different toolkits, this duplication is much harder to detect than simple code duplication.

An example of logic duplication is the way torrent file lists (the "Files" section in the torrent inspector that show what files/folders are contained in the torrent) are respectively handled by the Qt and Cocoa clients.

For reference, the logic for it is mainly in FileListNode, FileOutlineController and a bit in Torrent:createFileList() on the Cocoa side, and in file-tree and torrent:update() on the Qt side.

Both sides' GUI want the same thing: A list of hierarchized items representing files or folders as well as various properties (Name, Download Progress, Priority) for each of those items. The core library only has a list of files with their respective stats. It's up to the GUI code to organize those stats and to map them to row/columns. This is precisely where the logic duplication is. This organization of files in folders as well as the computation of aggregated stats is something that transcend a platform's specificity. Yet, this logic is being duplicated in both the Cocoa codebase and the Qt codebase. Even if it was specific to "rich GUI" platforms and irrelevant to, for example, command line clients, it's not a reason not to make a second "cross-rich-toolkit" codebase to put that kind of logic in. Here's a tiny example of duplicated logic:

// FileListNode.m:64
- (void) insertIndex: (NSUInteger) index withSize: (uint64_t) size
{
    NSAssert(fIsFolder, @"method can only be invoked on folders");

    [fIndexes addIndex: index];
    fSize += size;
}

and then:

// file-tree.cc:94
void
FileTreeItem :: getSubtreeSize( uint64_t& have, uint64_t& total ) const
{
    have += myHaveSize;
    total += myTotalSize;

    foreach( const FileTreeItem * i, myChildren )
        i->getSubtreeSize( have, total );
}

We can see that the logic for summing up folder's size is duplicated across toolkit-specific codebases. What's more disturbing is that the Qt side seems to compute an additional statistic for the folder (myHaveSize) that is not computed on the Cocoa side. This kind of "logic scattering" can potentially make debugging rather complicated (I couldn't figure out where that particular statistic was computed on the Cocoa side...).

As I wrote earlier, I have a shallow knowledge of Transmission's codebase. Given the good overall quality of its code, tolerating that duplication I've been writing about is probably a good decision from its developers. After all, Transmission's users have a relatively low interaction with its GUI.

Although my case against this design is weak, I think it's now possible to see how this kind of design would become problematic when the application requires a more complex interaction with the user through the GUI.

Case Study #3: moneyGuru

I'm commenting the 1.6.8 release.

With dupeGuru and musicGuru (both cross-toolkit applications) development under my belt, I knew what kind of problems I'd have with moneyGuru if I gave my toolkit-specific codebases too much responsibilities. Too often, I had bugs created simply because implementing something for the second time is boring (if you have logic duplication, it means you have to implement a fair part of your new features twice). You do it a little faster the second time around, and boom! you forget a detail.

Therefore, for moneyGuru development, I tried to make the toolkit-specific code as dumb as possible. That is why moneyGuru is designed so that the core has the "main control" of the application. Pretty much every controller, when interacted with, refers as directly as possible to the core codebase for instructions, which comes back to them in the forms of callbacks. Table views and tree views access their data through a thin layer of toolkit specific code which directly fetches it from the core, which already has that data pre formatted into ready-to-present rows. For example, tables on the Cocoa side have a datasource like this:

// controllers/MGTable.m
// "py" is a reference to the controller's equivalent in the core.
// Not such a good name, I know... 
- (int)numberOfRowsInTableView:(NSTableView *)tableView
{
    return [[self py] numberOfRows];
}

- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)column
    row:(int)row
{
    return [[self py] valueForColumn:[column identifier] row:row];
}

The bridge in mg_cocoa transforms those objc-ish calls into python calls:

# py/mg_cocoa (PyTable)
@objc.signature('i@:')
def numberOfRows(self):
    return len(self.py)

@objc.signature('@@:@i')
def valueForColumn_row_(self, column, row):
    return getattr(self.py[row], column)

With py being a reference to a core.gui.table.Table instance, whose job is to take model data and create rows and columns with pre-formatted data in it. On the PyQt side, it looks like this:

# controller.table
def _getData(self, row, column, role):
    if role in (Qt.DisplayRole, Qt.EditRole):
        return getattr(row, column.attrname)
    return None

def data(self, index, role):
    if not index.isValid():
        return None
    row = self.model[index.row()]
    column = self.COLUMNS[index.column()]
    return self._getData(row, column, role)

def rowCount(self, index):
    if index.isValid():
        return 0
    return len(self.model)

With model being a reference to the same core.gui.table.Table. With this design, there's very few logic duplication for data presenting matters, and it's still possible to do platform-specific customization by subclassing the base toolkit-specific table.

When the user interacts with the GUI, the toolkit-specific code almost never takes any decision about that interaction. For example, when the "New Item" button is clicked in moneyGuru, the Cocoa side directly calls the core's new_item() method:

// controllers/MGMainWindow.m
- (IBAction)newItem:(id)sender
{
    [py newItem];
}

The PyQt side does the same thing:

# controller.main_window
def newItemTriggered(self):
    self.model.new_item()

At the core, the core.gui.main_window.MainWindow instance takes all the decisions. It first determines which view is selected so that it can add the correct type of item:

# gui.main_window
def new_item(self):
    if self.top in (self.bsheet, self.istatement):
        self.top.add_account()
    elif self.top in (self.etable, self.ttable):
        self.top.add()
    elif self.top is self.sctable:
        self.scpanel.new()
    elif self.top is self.btable:
        self.bpanel.new()

If, for example, the "Net Worth" view was selected, a new account will be added by calling self.bsheet.add_account() (bsheet is a gui.balance_sheet.BalanceSheet):

# gui.report
def add_account(self):
    self.view.stop_editing()
    # [snip] determine account type and group based on current selection
    account = self.document.new_account(account_type, account_group)
    self.selected = self.find(lambda n: getattr(n, 'account', None) is account)
    self.view.update_selection()
    self.view.start_editing()

This is where things begin to be really interesting. Notice the calls to self.view. self.view is a reference to the toolkit-specific equivalent to this core instance. For example, update_selection() on a table is supposed to take the selection in the core instance (which we have just set in the line above) and to correctly reflect that selection in the view. On the Cocoa side, it looks like this:

// controllers/MGOutline.m
- (void)updateSelection // called in mg_cocoa.PyOutline.update_selection()
{
    [outlineView updateSelection];
}

- (NSIndexPath *)selectedIndexPath
{
    return a2p([[self py] selectedPath]);
}

// support/MGOutlineView.m
- (void)updateSelection
{
    NSIndexPath *selected = [[self delegate] selectedIndexPath];
    [self selectPath:selected];
}

And on the PyQt side:

# controller.account_sheet
def _updateViewSelection(self):
    # Takes the selection on the model's side and update the view with it.
    selectedPath = self.model.selected_path
    if selectedPath is None:
        return
    modelIndex = self.findIndex(selectedPath)
    self.view.setCurrentIndex(modelIndex)

def update_selection(self):
    self._updateViewSelection()

Even though different toolkits have different ways of handling certain operations, such has start_editing() and update_selection(), the moment at which these operations are made is still a cross-toolkit behavior. Therefore, it's possible to eliminate a great deal of GUI-related logic duplication with this application design.

With a design like this, I think it's possible to have your cake and mostly eat it too. Yes, it's still more work than single-toolkit software, but with this design, the price to pay for the nativeness of the software (and I think this nativeness is well worth the price) is mostly just counted in terms of extra work, rather than in terms of potentially bug-inducing logic duplication.

NOTE: Much of the credit for moneyGuru's design has to go to my brother Eric who worked on moneyGuru with me in its early development.

Conclusion

The option of doing cross-toolkit development seldom seems seriously considered by developers wanting to do cross-platform development. For people who didn't even realize it was possible, I hope I've convinced you that it's a viable path to take and that it has been taken before. For people who would think that cross-toolkit development implies too much extra code to maintain, I hope I convinced you that with a good design, it's possible to minimize the size of that extra code while still giving you the advantage of nativeness on all supported platforms.


This site is best viewed with Firefox while listening to The White Stripes