The Main Program

Most the code for the application is included in a single main.py file which contains a single Application class to manage the running of the application and a main function to start it.

Importing Modules

We begin by importing two standard Python modules that our application will need:

import os
import random
import sys
from . import config

The sys module provides access to the command line arguments used when the application was run. We use the random module to make our game interesting. The config module is one that is generated by the build system to enable us to access information about where the application and its resources are installed – see :ref:executable_template for details.

We also import modules that allow us to create user interfaces:

import gi
gi.require_version('GdkPixbuf', '2.0')
gi.require_version('Gtk', '3.0')
from gi.repository import GdkPixbuf, Gio, GLib, Gtk

gi.require_version('Handy', '0.0')
from gi.repository import Handy
Handy.init()

These are standard modules for accessing GTK and GNOME features, plus the Handy module that helps us to create adaptive user interfaces.

The Application Class

The application is represented by the Application class which is derived from the standard Gtk.Application class. This class provides methods to set up the application, respond to user interface events, and implement the rules of the game it presents to the user. See Using GtkApplication in the GNOME documentation for an overview of the lifecycle of an application.

The class is defined in the normal way, beginning with the __init__ method:

class Application(Gtk.Application):

    CellSize = 64

    def __init__(self):
        super().__init__(application_id='com.example.treasure')
        GLib.set_application_name(_('Treasure'))
        GLib.set_prgname('com.example.treasure')

        self.keyfile_path = os.path.join(GLib.get_user_data_dir(),
                                         'com.example.treasure', 'scores')

This method performs three things that are necessary for the application to run correctly:

  1. It uses the super built-function to call the __init__ method of the base class. This associates the application with the application ID given. The application ID must have a certain format. This is described in the Gio.GApplication documentation.
  2. It calls the GLib.set_application_name function to set a user-readable application name that will be localized if translations are available.
  3. It defines the location of the file containing user data. This will actually be used to hold the user’s best score.

Only the first two of these are critical. The third is just a convenience.

Application Start-up

The do_startup method is responsible for performing tasks that only need to be done at application start-up. In this example there are plenty of things that need to be done at this point.

    def do_startup(self):
        Gtk.Application.do_startup(self)

After calling the do_startup method of the base class, we can begin to set up the application, beginning by reading a style sheet from the application’s built-in resources:

        # Load a style sheet from the resources.
        self.css = Gtk.CssProvider()
        self.css.load_from_resource('/com/example/treasure/ui/style.css')

        # Load images from the application's data directory and store them
        # as pixbufs.
        self.pixbufs = {}

        for image in ['dug1.svg', 'dug2.svg', 'ground.svg', 'treasure.svg']:
            self.pixbufs[image] = GdkPixbuf.Pixbuf.new_from_file(
                os.path.join(config.pkgdatadir, 'images', image))

We also locate and read the application’s images from their installed locations on the system, as described in Data Files.

The application’s user interface is defined using UI files which we briefly covered in User Interface. The interface is constructed at run-time using the Gtk.Builder class. We set it up for use by creating an instance of this class and set its translation domain so that the user-visible text in the UI files can be translated using message catalogs – see Translations Directory. Then we add the UI files from the application resources so that we can create the window and menu:

        # Create a builder with the correct translation domain and load user
        # interface definitions from the resources.
        builder = Gtk.Builder()
        builder.set_translation_domain('treasure')
        builder.add_from_resource('/com/example/treasure/ui/menus.ui')
        builder.add_from_resource('/com/example/treasure/ui/window.ui')

        # Get the window from the builder.
        self.window = builder.get_object('window')
        self.window.set_property('application', self)

We use the builder to obtain widgets from the user interface it holds, beginning with the window itself which was bundled in the application’s resources. We set the window’s application property to link the window with the application. This ensures that the application runs until the window is closed – see the application property documentation for more information.

We also obtain the menu from the builder and add it to the menu_button widget, which is part of the window, as its menu model. This allows the menu button to show the menu that we defined:

        # Get the menu from the builder and apply it to the menu button.
        menu = builder.get_object('menu')
        menu_button = builder.get_object('menu_button')
        menu_button.set_menu_model(menu)

We connect the menu bundled in the application’s resources to program code via actions with the same name as those in the menus.ui file, as described in the Primary Menu section:

        # Create actions for the app menu items.
        new_game_action = Gio.SimpleAction.new('new_game', None)
        new_game_action.connect('activate', self.on_new_game)
        self.add_action(new_game_action)

        quit_action = Gio.SimpleAction.new('quit', None)
        quit_action.connect('activate', self.on_quit)
        self.add_action(quit_action)

By adding these actions to the Application instance, we cause them to be triggered when the user selects the corresponding menu items. Because we connect the active signal of each action to Application methods, those methods will be called when the user starts a new game or quits the application.

There are four more widgets that we need to access: the box that holds the main window area and the grid layout and two labels that are inside it. Again, we use the builder to obtain these widgets:

        # Get widgets that we will need to access later.
        self.box = builder.get_object('box')
        self.grid = builder.get_object('grid')
        self.turns_label = builder.get_object('turns_label')
        self.lowest_turns_label = builder.get_object('lowest_turns_label')

The application uses a data file to record the user’s best score so that the next time the game is played, the score is not forgotten. This value is read creating a GLib.KeyFile object to read the data file, and reading the appropriate integer value if the file exists. This is then used to update the corresponding label:

        try:
            self.keyfile = GLib.KeyFile()
            self.keyfile.load_from_file(
                self.keyfile_path,
                GLib.KeyFileFlags.KEEP_COMMENTS |
                GLib.KeyFileFlags.KEEP_TRANSLATIONS
            )
            self.lowest_turns = self.keyfile.get_integer(
                'general', 'lowest-turns'
            )
        except GLib.Error:
            self.lowest_turns = 0

        self.update_lowest_turns_label()

A default value of zero turns is used if the data file could not be read. This is a special value that we later use to allow the user’s first score to be recorded.

        self.cells = {}
        self.columns = 0
        self.rows = 0

The final few lines of the method initialize attributes that keep track of the widgets that will be placed in the window’s main area.

Application Activation

One of the shortest methods is the do_activate method because all of the initialization related to the application’s window is performed in the do_startup method:

    def do_activate(self):
        self.window.present()

The method simply calls the window’s present method to show it.

Event Handler Methods

In the do_startup method we connected the activated signals for the two menu actions to two methods in the Application class: on_quit and on_new_game. The first of these handles the action for quitting the application:

    def on_quit(self, action, parameter):
        self.quit()

Note that we do not perform any checks or ask the user whether they want to quit. More complex applications should check for any unsaved data and ask the user for guidance before simply shutting down.

The on_new_game method is longer and can be split into three sections. The first of these removes any cells in the grid left over from previous games, before calculating how much space there is to allocate a new set of cells for the new game. This calculation cannot be done before the window has been shown because there would be no easy way to determine how much space there is to fill.

    def on_new_game(self, action, parameter):

        # Remove all the widgets in the grid if present.
        if self.cells != {}:
            while self.rows > 0:
                self.grid.remove_row(0)
                self.rows -= 1
            while self.columns > 0:
                self.grid.remove_column(0)
                self.columns -= 1

        # Find the available space in the grid and allocate a reasonable number
        # of cells.
        width = self.box.get_allocation().width
        height = self.box.get_allocation().height

        # Record the numbers of rows and columns so that we can remove them
        # again the next time a new game is begun.
        self.columns = width // self.CellSize
        self.rows = height // self.CellSize

Using a fixed size for the cells that will be placed in the grid, we calculate the number of columns and rows that can fit into the available space.

With an empty grid to fill, we create a Gtk.Button to fill each cell and decorate it with an image based on one of the images loaded in the do_startup method.

        # Map tiles back to their locations.
        self.cells = {}

        # Create a widget for each tile.
        for row in range(self.rows):
            for column in range(self.columns):
                image = Gtk.Image.new_from_pixbuf(self.pixbufs['ground.svg'])
                button = Gtk.Button(image=image, relief=Gtk.ReliefStyle.NONE)

                # Apply a style defined in the style sheet to the button,
                # removing all borders and padding.
                style = button.get_style_context()
                style.add_provider(self.css, Gtk.STYLE_PROVIDER_PRIORITY_USER)
                style.add_class("tile")
                button.connect('button-press-event', self.on_clicked)

                self.grid.attach(button, column, row, 1, 1)

                self.cells[button] = (column, row)

        self.grid.show_all()

The button-press-event signal of each button is connected to the application’s on_clicked method so that we can handle clicks or touches on all of the cells in one place. Once all the cells have been filled, we update the window by showing the grid and all of its child widgets again.

The last part of the method involves setting up the game by hiding the treasure in one of the cells, resetting the counter holding the number of turns and its corresponding label, and resetting the flag indicating whether or not the player has won:

        # Hide the treasure in a cell.
        random.seed()
        # Choose a random row and column.
        self.treasure_pos = (random.randint(0, self.columns - 1),
                             random.randint(0, self.rows - 1))

        # Count the number of turns made.
        self.turns = 0
        self.update_turns_label()

        # Has the player won?
        self.won = False

A pair of methods used to update the labels in the window are implemented for convenience. They are interesting mostly because they use the convention of marking translatable strings as arguments to the underscore function:

    def update_turns_label(self):
        self.turns_label.set_text(_('Turns: {0}').format(self.turns))

    def update_lowest_turns_label(self):
        # Only update the label for valid values.
        if self.lowest_turns > 0:
            self.lowest_turns_label.set_text(
                _('Lowest number of turns so far: {0}').format(
                    self.lowest_turns))

The underscore function _() is installed when the program executable is run – we will see how this is done in the Executable Template section.

Handling Clicks

The on_clicked method handles the signals sent by buttons when they are clicked or touched by the player. If the player has already won or the cell they clicked is already revealed, we return immediately. Otherwise, we increase the number of turns and update the corresponding label.

    def on_clicked(self, widget, event):

        if self.won:
            return

        pos = self.cells[widget]

        if widget.get_image().get_pixbuf() != self.pixbufs['ground.svg']:
            return

        self.turns += 1
        self.update_turns_label()

The rest of the method handles the check for the treasure location, changing the button’s image to an open treasure chest if the player guessed the location. The ngettext function is used to select the appropriate string to display for the current language:

        if pos == self.treasure_pos:

            self.won = True

            widget.set_image(Gtk.Image.new_from_pixbuf(
                self.pixbufs['treasure.svg']))

            self.turns_label.set_text(
                ngettext('Well done! You found the treasure in {0} turn.',
                         'Well done! You found the treasure in {0} turns.',
                         self.turns).format(self.turns))

            if self.lowest_turns == 0 or self.turns < self.lowest_turns:
                self.save_score()

        else:
            if random.randint(0, 1) == 0:
                pixbuf = self.pixbufs['dug1.svg']
            else:
                pixbuf = self.pixbufs['dug2.svg']

            widget.set_image(Gtk.Image.new_from_pixbuf(pixbuf))

If the treasure was not in the cell that was clicked then we choose one of two images to display in the cell.

Saving the Score

The save_score method is called when a new best score is obtained. It updates the lowest score and updates the corresponding label in the window. In addition, it sets the appropriate value in the GLib.KeyFile object so that the data file can be updated:

    def save_score(self):

        self.lowest_turns = self.turns
        self.update_lowest_turns_label()
        self.keyfile.set_integer('general', 'lowest-turns', self.lowest_turns)

The location of this file was defined at the start of the Application class. It is located within the common directory for user data, inside a subdirectory of its own. We check that this subdirectory exists, and create it if necessary, before writing the file:

        try:
            data_dir = os.path.split(self.keyfile_path)[0]
            if not os.path.exists(data_dir):
                os.mkdir(data_dir)

            self.keyfile.save_to_file(self.keyfile_path)
        except (OSError, GLib.Error):
            pass

If writing fails for some reason, there isn’t much we can do about it, so we try to fail gracefully by catching either of the two exceptions that might be raised.

Main Function

We provide a main function to follow the convention used by GNOME Builder:

def main(version):

    app = Application()
    return app.run(sys.argv)

This is called by the executable that is run when the user launches the application. This is covered in the Executable Template section.