Software | Secret Software | Writing
Programming GNOME Applications with Perl, Part Two
Simon Cozens
In last month's article, we looked at how to create a very simple "Hello World" application using Gtk+ and GNOME. This month, we're going to start to build up a more sophisticated application, one to store and retrieve recipes.
The Cookbook Application
Before we write a line of code, let's have some idea about how we're going to design this. We'll look first at the user interface, and then see what that implies for our program design.
When designing user interfaces, we need to consider the question of what presents the user with the most useful and intuitive view of their data, without overcrowding them. What do we need to be able to get at easily when we're using the application? There are two parts to this question: actions that we can perform, and data we can see.
In terms of the data, I decided that the best way to organise the available recipes was as a list, just like the table of contents in a recipe book; scroll up and down the list to see the recipe titles, and then click on one title to display the whole recipe. We could also display some useful information next to each title: I decided that the most useful things to know would be the cooking time and the date that the recipe was added.
Now we can look at the actions to be performed - these would turn into the toolbar buttons. One of the most useful features I wanted would be the ability to give the program a list of ingredients that I have and have it tell me things I could cook with them. I also wanted to be able to maintain several different cookbooks, so "Save" and "Open" were natural choices. Of course, you need to be able to add new recipes, so an "Add" button would be useful too. Note that I didn't want a "Delete" button - deleting a recipe is something that'll probably only happen rarely, and even then, you don't want to make it too easy to do. Finally, of course, you need to be able to exit.
That's the interface out of the way for the main screen, and this is what it would look like:
Now we can think of the data we need to store; obviously, we'll need to store the actual recipes somewhere, together with their titles, dates and cooking times. If we want to search by ingredient, we should also store what ingredients each recipe needs along with it. It would also be handy to have a complete list of all the ingredients we know about, and we'll also have some user configuration settings.
Initially, I considered putting the recipes in an SQL database, but decided against it for two reasons: first, connecting recipes to ingredients was unnecessarily complicated, and the whole thing seemed a little overkill, and secondly, GNOME applications traditionally store all their data in XML files so that data can be easily passed between apps. In the end, I decided to store the configuration settings plus the list of ingredients we know about in a single XML file, and have the recipe book in a separate file.
The Main Screen
Now we know what the interface is going to look like for the main screen, we can start coding it up. We'll start with the menu items and the toolbar, just like before.
#!/usr/bin/perl -w
use strict;
use Gnome;
my $NAME = 'gCookBook';
my $VERSION = '0.1';
init Gnome $NAME;
my $app = new Gnome::App $NAME, $NAME;
signal_connect $app 'delete_event', sub { Gtk->main_quit; return 0 };
$app->create_menus(
{
type => 'subtree',
label => '_File',
subtree => [
{
type => 'item',
label => '_New',
pixmap_type => 'stock',
pixmap_info => 'Menu_New'
},
{
type => 'item',
label => '_Open...',
pixmap_type => 'stock',
pixmap_info => 'Menu_Open'
},
{
type => 'item',
label => '_Save',
pixmap_type => 'stock',
pixmap_info => 'Menu_Save'
},
{
type => 'item',
label => 'Save _As...',
pixmap_type => 'stock',
pixmap_info => 'Menu_Save As'
},
{
type => 'separator'
},
{
type => 'item',
label => 'E_xit',
pixmap_type => 'stock',
pixmap_info => 'Menu_Quit',
callback => sub { Gtk->main_quit; return 0 }
}
]
},
{
type => 'subtree',
label => '_Edit',
subtree => [
{
type => 'item',
label => 'C_ut',
pixmap_type => 'stock',
pixmap_info => 'Menu_Cut',
},
{
type => 'item',
label => '_Copy',
pixmap_type => 'stock',
pixmap_info => 'Menu_Copy'
},
{
type => 'item',
label => '_Paste',
pixmap_type => 'stock',
pixmap_info => 'Menu_Paste'
}
]
},
{
type => 'subtree',
label => '_Settings',
subtree => [
{
type => 'item',
label => '_Preferences...',
pixmap_type => 'stock',
pixmap_info => 'Menu_Preferences',
callback => \&show_prefs
}
]
},
{
type => 'subtree',
label => '_Help',
subtree => [
{type => 'item',
label => '_About...',
pixmap_type => 'stock',
pixmap_info => 'Menu_About',
callback => \&about_box
}
]
}
);
$app->create_toolbar(
{
type => 'item',
label => 'Cook',
pixmap_type => 'stock',
pixmap_info => 'Search',
hint => 'Find a recipe by ingedients'
},
{
type => 'item',
label => 'Add',
pixmap_type => 'stock',
pixmap_info => 'Add',
hint => 'Add a new recipe'
},
{
type => 'item',
label => 'Open...',
pixmap_type => 'stock',
pixmap_info => 'Open',
hint => "Open a recipe book"
},
{
type => 'item',
label => 'Save',
pixmap_type => 'stock',
pixmap_info => 'Save',
hint => "Save this recipe book"
},
{
type => 'item',
label => 'Exit',
pixmap_type => 'stock',
pixmap_info => 'Quit',
hint => "Leave $NAME",
callback => sub { Gtk->main_quit;}
}
);
$app->set_default_size(600,400);
my $bar = new Gnome::AppBar 0,1,"user" ;
$bar->set_status("");
$app->set_statusbar( $bar );
show_all $app;
main Gtk;
sub about_box {
my $about = new Gnome::About $NAME, $VERSION,
"(C) Simon Cozens, 2000", ["Simon Cozens"],
"This program is released under the same terms as Perl itself";
show $about;
}
Columned Lists
Next, we have to show the list of recipes. The usual way to do this is with a CList, or "columned list", widget. However, the standard Gtk CList widget is a little unfriendly to deal with: you can only put data into it, and you can't find out what's in the list, so you have to maintain a separate array containing the data; columned lists usually resort themselves when a column title is clicked on, but the programmer has to handle this case themself; data has to be referenced by column number, not by column name, and so on.
Since I realised this was going to be unpleasant every time I wanted a columned list, I wrote a module called Gtk::HandyCList which encapsulates all these features. (You'll need to download that module from CPAN if you want to try this example. Make sure you get version 0.02, since we use the hide method down below, which is new in that version.)
To add it to our program, we first need some data to display! Let's just create a dummy array of data, like this:
my @cookbook = (
[ "Frog soup", "29/08/99", "12"],
[ "Chicken scratchings", "12/12/99", "40"],
[ "Pork with beansprouts in a garlic butter sauce and a really really long name that we have to scroll to see",
"1/1/99", 30],
[ "Eggy bread", "10/10/10", 3]
);
Now, of course, we need to load the module itself, so:
use Gtk::HandyCList;
Because we want this list to be scrollable, we put it inside a different widget which handles the scroll bars for us, a Gtk::ScrolledWindow.
my $scrolled_window = new Gtk::ScrolledWindow( undef, undef ); $scrolled_window->set_policy( 'automatic', 'always' );
Now we create the HandyCList: we first tell it the column names we are going to use, then set up the sizes for each column.
my $list = new Gtk::HandyCList qw(Name Date Time); $list->sizes(350,150,100);
As I mentioned, we want to be able to resort the data when the column headings are clicked on. To make this work, of course, we have to tell the module how to sort each column: it knows about alphabetical sorting and numeric sorting, but we'll have to tell it about sorting by date by providing it with a subroutine reference. We also set the shadow so that it looks pretty.
$list->sortfuncs("alpha", \&sort_date, "number");
$list->set_shadow_type('out');
Now we give the data to the list:
$list->data(@cookbook);
Next, we add the list to our scrolled window, and tell the application that its main contents are the scrolled window:
$scrolled_window->add($list); $app->set_contents($scrolled_window);
Finally, we'll receive the signal sent when a recipe is clicked on, and use that to display the recipe.
$list->signal_connect( "select_row", \&display_recipe);
Of course, we need to write those two subroutines, sort_date and display_recipe. Let's leave the latter one for now, and polish off the date sorting. Here's how I'd write it, because I'm British:
sub sort_date {
my ($ad, $am, $ay) = ($_[0] =~ m|(\d+)/(\d+)/(\d+)|);
my ($bd, $bm, $by) = ($_[1] =~ m|(\d+)/(\d+)/(\d+)|);
return $ay <=> $by || $am <=> $bm || $ad <=> $bd;
}
Exercise for the reader: make this subroutine locale-aware.
By now, however, you should have an application which displays a list of recipes along with their dates and cooking times. Play with it, click on the column headings and watch it resort; resize the windows and the columns, and see what happens.
Displaying Recipes
Now let's tackle displaying the recipes. This is where things get a little more complex. Firstly, we have to actually store the text of the recipes somewhere: we really want to store them along with the titles and dates and cooking times, in the @cookbook array. So let's add another column to that array, like so:
my @cookbook = (
[ "Frog soup", "29/08/99", "12", "Put frog in water. Slowly raise water temperature until frog is cooked."],
[ "Chicken scratchings", "12/12/99", "40", "Remove fat from chicken, and fry under a medium grill"],
[ "Pork with beansprouts in a garlic butter sauce and a really really long name that we have to scroll to see",
"1/1/99", 30, "Pour boiling water into packet and stir"],
[ "Eggy bread", "10/10/10", 3, "Fry bread. Fry eggs. Combine."]
);
Now, of course, we don't want to display this information on the main list, so we need to slightly change the data that we're passing to the Gtk::HandyCList:
- my $list = new Gtk::HandyCList qw(Name Date Time);
+ my $list = new Gtk::HandyCList qw(Name Date Time Recipe);
+ $list->hide("Recipe");
(If you don't remember what that syntax means, it's "take out the line starting with the minus, and add in the lines starting with a plus.")
So now we've got the recipes stored inside our data structure, we want to be able to see them somehow. We'll use a widget called Gnome::Less, which is named after the Unix utility less. It's a file browser, but we can also give it strings to display.
Let's stop and think about what we're going to do. We need to catch the signal that tells us that the user has double-clicked on a recipe. Then we want to pop up a window, create a Gnome::Less widget inside that window containing the recipe text, and allow the user to dismiss the window. We've already connected the "mouse click" signal to a subroutine called display_recipe, so it's time to write that subroutine.
sub display_recipe {
my ($clist, $row, $column, $mouse_event) = @_;
return unless $mouse_event->{type} eq "2button_press";
First, we receive the parameters passed by the signal: the first thing we get is the object which caused the signal, our HandyCList widget. That then determines what other parameters get sent: in the case of a HandyCList, it's the row and column in the list which received the mouse click, and a Gtk::Gdk::MouseEvent object which tells us what sort of click it was: in our case, we only want to act on a double click, which is where the type is "2button_press". If this isn't the case, we return.
my %recipe = %{($clist->data)[$row]};
Given that we know the row that received the signal, we can get extract that row from the HandyCList via the data method - data is a get-set method; this means we can either store data into the list with it, or we can use it to retrieve the data from the list. Each row is stored as a hash reference, which we dereference to a real hash.
my $recipe_str = $recipe{Name}."\n";
$recipe_str .= "-" x length($recipe{Name})."\n\n";
$recipe_str .= "Cooking time : $recipe{Time}\n";
$recipe_str .= "Date created : $recipe{Date}\n\n";
$recipe_str .= $recipe{Recipe};
Next, we build up the string that we're going to display, using the hash values we've recovered.
my $db = new Gnome::Dialog($recipe{Name});
my $gl = new Gnome::Less;
my $button = new Gtk::Button( "Close" );
$button->signal_connect( "clicked", sub { $db->destroy } );
We now create three widgets: the pop-up dialog box window, (we pass the recipe's name as a window title) the pager which will display the recipe, and a close button. We also connect a signal so that when the button is clicked, the dialog box is destroyed.
$db->action_area->pack_start( $button, 1, 1, 0 );
$db->vbox->pack_start($gl, 1, 1, 0);
A dialog box consists of two areas: an "action area" at the bottom which should contain the available "actions", or buttons, and a vbox at the top into which we put our messages. Accordingly, we pack our button into the action area and our Less widget into the vbox
$gl->show_string($recipe_str);
show_all $db;
}
Finally, we tell the pager what string it is to display, and then show the dialog box. We can now display recipes.
Where we are, and where we're going
The full source of the application so far can be found here.So far, we've only dealt with static data, hard-coded into the application, which isn't a very real-life scenario. Next time, we'll look at adding and deleting recipes, as well as saving and restoring cookbooks to disk using XML. Once that's done, we'll have the core of a very basic cookbook application. In the final part of this tutorial, we'll add some extra features, such as searching by ingredients.
Notes on the last article
Several people wrote to me after last month's article saying that they couldn't get the GNOME versions of the application working; if that's a problem for you, you need to be using the very latest version of the Gnome.pm module. The one on CPAN is not the very latest - instead, use the one from the Gnome.pm website, at http://projects.prosa.it/gtkperl
I also got my knuckles rapped for saying that "GNOME is the Unix desktop". Fair play - the other project that's providing the same sort of environment for Unix is KDE, but for a long time it was hampered by developers' suspicion of TrollTech and their QPL license. At the same time, a long of big players like Sun and IBM were putting money into the GNOME Foundation to make GNOME the Unix desktop, so it seemed a fair thing to say.
Now most people are happy that TrollTech are being above board, and exactly the same big players have also set up the KDE League, (From http://www.kde.org/announcements/gfresponse.html: `Now we have been asked "Will KDE ever create a KDE Foundation in the same sense as the GNOME Foundation?" The answer to this is no, absolutely not.' You tell 'em, guys.) KDE looks to be a worthy alternative to GNOME. Obviously, I prefer GNOME, but as http://segfault.org puts it: "KDE - GNOME War - Casualties so far: 0".