GTK spike for Ebookrack Finder

Objective

To create a skeleton GUI without any business logic using the GTK+ 3 libraries and PyGObject bindings.

This is to get a feel for how the Python interface to GTK works and whether such a GUI would satisfy the requirements for ebk-find.

Useful reference: The Python GTK+ 3 Tutorial

Sidenote: Why use GTK 3 instead of GTK 4? Because the GTK 3 API is stable, since the GNOME project no longer uses it. The tutorials for GTK 4 also emphasise using XML to define the UI, which introduces too much complexity for a simple app.

Setup

To install the dependencies on Debian/Ubuntu:

sudo apt install python3-gi gir1.2-gtk-3.0
/usr/bin/python3 -c "import gi" && echo OK

For other platforms, see the GTK docs.

Check that the libraries can be imported by creating a ebk-find-gtk.py script with this content:

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio

Check that it runs without errors:

python3 ebk-find-gtk.py

GUI skeleton

Add this basic structure for the app to ebk-find-gtk.py:

class App(Gtk.Application):
    def do_activate(self):
        window = AppWindow(app=self)
        window.show_all()

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(
            application=app,
            title="Main Window",
            default_height=480,
            default_width=480,
        )

if __name__ == "__main__":
    import sys
    app = App(application_id="com.example.MyApp")
    exit_code = app.run(sys.argv)
    sys.exit(exit_code)

Adding a menu

Let’s add a menu with a single “Quit” entry. The XML describing the menu looks like this, with “quit” as the name of the action to bind to the menu item in the app and <Primary>q (Ctrl-Q) as its keyboard shortcut:

MENU_XML = """
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <menu id="app-menu">
    <item>
      <attribute name="action">app.quit</attribute>
      <attribute name="label" translatable="yes">_Quit</attribute>
      <attribute name="accel">&lt;Primary&gt;q</attribute>
    </item>
  </menu>
</interface>
"""

Configure the app to use that menu:

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

        builder = Gtk.Builder.new_from_string(MENU_XML, length=-1)
        self.set_app_menu(builder.get_object("app-menu"))

For some reason, it has to be Gtk.Application.do_startup(self) rather than super().do_startup(self).

The menu entry (and keyboard shortcut) don’t do anything yet because we haven’t defined the app.quit action.

Defining an action

Let’s define an on_quit handler and bind it to an action named “quit” (the name has to match the “action” attribute in the menu XML):

class App(Gtk.Application):
    def do_startup(self):
        ...
        quit = Gio.SimpleAction.new("quit", None)
        quit.connect("activate", self.on_quit)
        self.add_action(quit)

    def on_quit(self, _action, _param):
        self.quit()

Defining the layout

Let’s lay out our window with a search box and a results box below it. The results box might need to hold many entries, so we make it scrollable and expandable. We also add a label above each box.

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...

        search_label = Gtk.Label(label="Filter titles in library")
        search = Gtk.SearchEntry()

        results_label = Gtk.Label(label="Press down to select next result")
        results = Gtk.ScrolledWindow()
        results.set_vexpand(True)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.add(search_label)
        vbox.add(search)
        vbox.add(results_label)
        vbox.add(results)
        self.add(vbox)

Defining a model and view

We’ll use a Gtk.TreeModel to hold our data and a Gtk.TreeView to display it.

We initialise the model with some dummy data:

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...
        self.data = init_model()
        ...

def init_model() -> Gtk.TreeModel:
    data = Gtk.ListStore(int, str)

    for i, title in enumerate(BOOKS):
        data.append((i, title))

    return data.filter_new()

BOOKS = [
    "Antony and Cleopatra",
    "Hamlet",
    "Julius Caesar",
    "King Lear",
    "Macbeth",
    "The Merchant of Venice",
    "A Midsummer Night's Dream",
    "Much Ado About Nothing",
    "Othello",
    "Romeo and Juliet",
    "The Tempest",
    "Titus Andronicus",
]

Then we create a 2-column view and insert it into the results box:

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...
        results.add(new_view(self.data))
        ...

def new_view(data: Gtk.TreeModel):
    view = Gtk.TreeView(model=data)
    column = Gtk.TreeViewColumn("#", Gtk.CellRendererText(), text=0)
    view.append_column(column)
    column = Gtk.TreeViewColumn("Title", Gtk.CellRendererText(), text=1)
    view.append_column(column)
    return view

Filtering the view

We want the results to only show titles matching the query.

We store the query as a list of words, and hide any entries that don’t contain all of those words.

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...
        self.query = ["and"]
        self.data.set_visible_func(self.is_match)

    def is_match(self, model, iterator, _data) -> bool:
        title = model[iterator][1].lower()
        return all(word in title for word in self.query)

The query is hard-coded for now. We’ll fetch it from the search box in the next section.

Handling user input

To update the view when the user types into the search box, we connect an events handler to the search box. This handler updates the stored query with the new contents of the search box, and tells the data model to update itself accordingly.

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...
        search.connect("search-changed", self.on_search_changed)

    def on_search_changed(self, entry, *args):
        self.query = entry.get_text().split()
        self.data.refilter()

Note that the model retains all the original data; it doesn’t discard non-matches when it does the refilter. So if the user erases (part of) their query, the rows that were filtered out reappear.

Updating the contents of a label

As a finishing touch, let’s add a label for the number of matches below the results box. The text also needs to be updated when the search string changes.

class AppWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        ...
        self.count = Gtk.Label()
        self.update_count()
        vbox.add(self.count)

    def on_search_changed(self, entry, *args):
        ...
        self.update_count()

    def update_count(self):
        self.count.set_text("{n} matches".format(n=len(self.data)))

Conclusions

The app uses a bit more memory than a CLI/TUI app (the baseline seems to be about 60 MB). But it starts up fast and looks pretty good.

It is much more user-friendly than the curses version for roughly the same amount of application code.

The PyGObject API is clearly not very pythonic. I’m pretty sure they deliberately keep it close to the API of the C libraries so that the Python library can be generated from the C source code.

The API docs for the GTK 3 Python library are no longer available online, so you have to use the docs for the C library. The Python GTK+ 3 Tutorial is reasonably easy to follow, though.

Appendix

Here is the full script: ebk-find-gtk.py