Adding GUI to Sequential Python Scripts Using Generators

* This post was written in jupyter and then pasted into wordpress *

In this post I will explain a method I came up with at work to add a graphical user interface (GUI) to a sequential python script – using generators.

Most tutorials on python generators focus on the advantage for writing iterators. For example the first line in https://wiki.python.org/moin/Generators is:

"Generators functions allow you to declare a function that behaves like an iterator, i.e. it can be used in a for loop."

That’s true and great. But in this post I will not use generators for iteration (at least not explicitly), but rather for their special ability to halt a function and later resume it.

At work we do a lot of exepriments with sound and temperature, and we write sequential scripts for them that we run in a terminal (or idlespork, my favorite IDLE fork). When we started working with volunteers, we wanted them to see some graphs on the screen, but also we didn’t want them to see an ugly terminal. We needed to add a GUI. The point I want to make is this:

Experiments scripts are sequential in their nature:

1. Enter details
2. Turn on oven and press enter
3. Turn off oven and press enter
4. Enter some data
5. Goodbye!

Usually, on the other hand, GUI is not sequential. It is object-oriented and event-driven. And that’s great of course. I love writing GUI in the usual way (I’m kidding, no one likes to write GUI). So how do we take working sequetial python scripts that need user input and add a GUI to it, with as little work as possible?

Step 0 – Have some code

The running example of this post will be a small script that lets you compute the number of beats per minute (BPM) of a song by manually pressing enter on each beat for, say, 30 seconds. Here it is:

In [1]:
import time


def bpm():
    print("[  0] press enter on every beat ", end='')
    input()
    
    s = time.time()
    beats = 1
    
    print("[  0] press enter on every beat ", end='')
    
    while input() == '':
        print("[%3d] press enter on every beat " % (beats*60/(time.time() - s)), end='')
        beats += 1

The code is simple and sequential:

1. wait for first enter press,
2. wait for more enter presses,
3. on every enter press calculate the new BPM approximation.

I put on Benny Goodman and Charlie Christian’s A Smo-O-Oth One and ran it:

In [3]:
bpm()
[  0] press enter on every beat 
[  0] press enter on every beat 
[118] press enter on every beat 
[126] press enter on every beat 
[127] press enter on every beat 
[129] press enter on every beat 
[129] press enter on every beat 
[130] press enter on every beat 
[128] press enter on every beat exit

Writing this post was the first time I used input() in jupyter, and I was happily surprised to find it works.

Step 1 – Refactor screen printing

Displaying stuff is different when using a terminal than when using a GUI. By refactoring out the printing parts of the function, we get ready to replace them with a GUI statement.

In [4]:
def display_terminal(value):
    print("[%3d] press enter on every key " % value, end='')

    
def bpm(displayfunc=display_terminal):
    displayfunc(0)
    input()
    
    s = time.time()
    beats = 1
    
    displayfunc(0)
    
    while input() == '':
        displayfunc(beats * 60 / (time.time() - s))
        beats += 1

Step 2 – Yieldify / Refactor out user input

Here we start to use generators. Instead of telling the script to wait for user input inside our function, we will have something external to get input, send it to our function, and then our function will yield back control to the external function.

This is how this looks, with comments indicating the two changed lines:

In [5]:
def bpm_gen(displayfunc=display_terminal):
    displayfunc(0)
    # input()
    yield
    
    s = time.time()
    beats = 1
    
    displayfunc(0)
    
    # while input() == '':
    while (yield) == '':
        displayfunc(beats * 60 / (time.time() - s))
        beats += 1
        

def bpm_loop():
    gen = bpm_gen()
    last_input = None
    while True:
        try:
            gen.send(last_input)
            last_input = input()
        except StopIteration:
            break

The function bpm_loop creates a bpm generator and then relies on the send mechanism to feed the generator with user input. This is where we’re using a generator in a slightly different way than normal – instead of iterating, we keep sending values.

The major point here is this:

We have converted waiting for user input in our function to waiting for our function in a loop that gets user input

This is possible due to the ability of generators to halt and later resume.

Step 3 – Add a GUI

We are ready to add actual GUI components. All we need to do is replace the bpm_loop function above with something else that calls gen.send.

This is how this looks with Tkinter:

In [6]:
import tkinter


def bpm_gui():
    # Create a window
    top = tkinter.Tk()
    top.minsize(width=400, height=50)
    top.maxsize(width=400, height=50)

    # Create a button
    beat = tkinter.Button(top, text="[  0] click on every beat", font="courier")
    beat.place(x=0, y=0)
    
    # New display function that sets the button label
    def display_label(value):
        beat.config(text="[%3d] click on every beat" % value, font="courier")
    
    # Create a bpm generator
    gen = bpm_gen(displayfunc=display_label)
    # Start the generator, i.e. get to first yield
    next(gen)
    
    # Configure the button to call gen.send on each click
    beat.config(command=lambda: gen.send(''))

    top.mainloop()

bpm_gui creates a Tkinter window, adds a button, creates a bpm generator that displays BPM approxiamtions in the button’s label, and finally configures the button to call the generator’s .send.

That’s it! We have added a GUI to a sequential script with as little change to the original function as possible. You can imagine how these three steps will work for basically any sequential terminal script. For textual user input (and not just pressing enter), we factor out the input statement to a function that reads GUI text components, and then it can hide them and reorganize the main window. Even if we do this, we still keep our core logic sequential – we don’t have to encapsulate the core in a class, initialize members, and change the way we think of the code.

Step 4 – Run in a seperate thread

For the running example there’s no problem of speed, but at work we were playing music through headphones for a few seconds after each user input. This caused the GUI main loop to be stuck and it would show signs of dying (on Ubuntu the application window goes dark, on Windows you get the alarming “Not responding” in the title).

To fix this we must make sure the core code, which is now in a generator, be run in a seperate thread. I implemented this by sharing an Event object between the two threads: the logic thread would wait on the event, and the GUI thread would set it when needed. There are more ways to do this and it really depends on the type of the user input you have.

In any case the code to communicate events can sit in the external functions – the core logic doesn’t need to be changed again. It remains sequential and pretty close to the original code you had.

Step 5 – Adding more stuff / Going overboard

Now that the original code works with a GUI, I can actually add something that I’ve wanted for a while: to see the distributon of BPM approximations with every beat. I.e. draw a matplotlib histogram figure on a canvas.

In [7]:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2TkAgg
from matplotlib.figure import Figure
import matplotlib.pyplot as plt


def bpm_hist_gui():
    top = tkinter.Tk()

    # Create button
    beat = tkinter.Button(top, text="[  0] click on every beat", font="courier")
    beat.grid(row=1, column=1, sticky=(tkinter.W, tkinter.E))
    
    # Create a canvas
    fig = Figure(figsize=(5,4), dpi=100)
    canvas = FigureCanvasTkAgg(fig, master=top)
    canvas.get_tk_widget().grid(row=2, column=1, sticky=(tkinter.W, tkinter.E))
    
    # Variable to keep bpms
    bpms = []
    
    # New display function that sets the button label
    def display_label(value):
        beat.config(text="[%3d] click on every beat" % value, font="courier")
        
        if value > 0:
            # Add value to bpms
            bpms.append(value)
            
        if len(bpms) > 10:
            # Draw new histogram
            fig.clear()
            p = fig.gca()
            # I actually want to see the BPM as calculated by every two consecutive beats/clicks.
            diffs = [1/((i + 1) / b2 - i / b1) for i, (b1, b2) in enumerate(zip(bpms[:-1], bpms[1:]))]
            p.hist(diffs, 20)
            p.set_xlabel('BPMs', fontsize = 15)
            p.set_ylabel('Frequency', fontsize = 15)
            canvas.show()
    
    # Create the generator
    gen = bpm_gen(displayfunc=display_label)
    # Start the generator, i.e. get to first yield
    next(gen)
    
    # Configure the button to call gen.send on each click
    beat.config(command=lambda: gen.send(''))

    top.mainloop()

bpm_gui

The End