Programming 1, 2022-3
Week 12: Notes

model-view architecture

We have now learned enough about Tkinter to begin to write some interesting graphical programs.

In many programs it's helpful to separate the code into a model and a view. The model is a class (or set of classes) that represent the state of the world and the rules for updating state. For example, if we are writing a chess game, there could be a model class Game that represents a game of chess. It might have a constructor Game() that constructs a new game. It could also have methods such as

Model classes know nothing about the user interface. If we want to write several different user interfaces, we should be able to use the same model class(es) for each of them.

The view is a class (or set of classes) that is responsible for rendering the model, i.e. drawing the state of the world.

In this form of architecture, there is often an additional component called a controller that is responsible for processing input. When input arrives, the controller receives it and calls model methods to update the state of the world. Then someone (either the controller or the model, depending on the program) notifies the view that the state has changed and it's time to redraw it. The view makes calls model methods to learn about the state that needs to be displayed.

In my experience, this sort of architecture leads to cleaner code than in programs where the model and view code are tangled together. I'd encourage you to use it when writing many graphical programs (or even when writing some programs with a textual user interface).

example: Tic-Tac-Toe

As an example of model-view architecture, let's design and write a program that allows two players to play Tic-Tac-Toe.

model

First let's consider the model. We will have a single model class Game that represents a game in progress. In our program we will have only a single instance of Game. (If we wrote an AI player for the game, it might create additional Game objects representing hypotethetical game states that it explores. We will see how to do this in Programming 2 next semester.)

What methods should our Game class have? Well, we need an initializer to create a new Game. We should also have a method move(x, y) that makes a new move at the given position.

We will also need to have methods or attributes that the view can use to learn about the current game state. We can have a two-dimensional array 'at' that holds the contents of the board. Each element at[x][y] will be an integer: 0 means the square is empty, or a 1 or 2 mean that player 1 or 2 have moved there. In theory we could use strings such as ' ', 'X' and 'O'. However, strictly speaking even that would be putting some knowledge of the view into the model. The model is not supposed to know how the marks on the board will appear.

Additionally, let's have a boolean attribute 'game_over' that becomes true when the game is done, and an integer attribute 'winner' that holds the player number who won, or 0 if the game was a draw. Finally, since the view will need to display the winning squares, let's have an attribute 'winning_squares' that holds a list of their coordinates.

Here is an implementation of the model:

# model

class Game:
    def __init__(self):
        self.at = [3 * [0] for _ in range(3)]   # 3 x 3 array: 0 = empty, 1 = X, 2 = O
        self.turn = 1    # whose turn it is to play
        self.moves = 0   # number of moves so far
        self.game_over = False
        self.winner = 0  # player who won, or 0 = draw
        self.winning_squares = []
        
    def check(self, x, y, dx, dy):
        at = self.at
        if at[x][y] > 0 and at[x][y] == at[x + dx][y + dy] == at[x + 2 * dx][y + 2 * dy]:
            self.game_over = True
            self.winner = at[x][y]
            self.winning_squares = [(x + i * dx, y + i * dy) for i in range(3)] 
    
    def check_win(self):
        for i in range(3):
            self.check(0, i, 1, 0)  # check row
            self.check(i, 0, 0, 1)  # check column
        self.check(0, 0, 1, 1)      # check diagonal \
        self.check(2, 0, -1, 1)     # check diagonal /
    
    def move(self, x, y):
        if self.at[x][y] == 0:   # square is available
            self.moves += 1
            self.at[x][y] = self.turn   # place an X or O
            self.turn = 3 - self.turn  # switch players
            self.check_win()
            if self.moves == 9:
                self.game_over = True

view

Now let's design the view. The board consists of a 3 x 3 grid of squares. Let's make the squares 100 pixels wide and tall. Additionally, let's plan to have a 50-pixel margin around the board. We could embed the constants 100 and 50 in various places throughout our code, but it's better to give names to them:

MARGIN = 50     # margin size in pixels
SQUARE = 100    # square size in pixels

In this and many other graphical programs, it is convenient to invent a custom coordinate system in which we can do our drawing. Let's use a coordinate system in which the square are only 1 unit high and 1 unit wide, and there is no margin. In this coordinate system the upper-left corner of the board will be at position (0, 0), and the lower-right will be at (3,0). Then, for example, the two vertical lines that form part of the board will extend from (1, 0) to (1, 3) and from (2, 0) to (2, 3).

In some graphics toolkits you can specify a custom coordinate system such as this one, and then all drawing commands will work with those coordinates. Tkinter does not have this feature, however we can simulate it easily. Let's write a function that maps an x- or y-coordinate in our coordinate system (which we will call user coordinates) into pixel coordinates:

def coord(x):
    return MARGIN + SQUARE * x

And now to draw at (x, y) in user coordinates, we need only draw at (coord(x), coord(y)) in pixel coordinates.

Our view class will be a subclass of the Tkinter Canvas class. The built-in canvas methods that create lines, rectangles and ovals work in pixel coordinates. Let's make convenience methods that will do the same in user coordinates:

class View(Canvas):
    ...

    def line(self, x1, y1, x2, y2, **args):
        self.create_line(coord(x1), coord(y1), coord(x2), coord(y2), **args)

    def rectangle(self, x1, y1, x2, y2, **args):
        self.create_rectangle(coord(x1), coord(y1), coord(x2), coord(y2), **args)

    def oval(self, x1, y1, x2, y2, **args):
        self.create_oval(coord(x1), coord(y1), coord(x2), coord(y2), **args)

The View class will have an attribute 'game' that points to the model. When the user clicks the mouse, we'll call self.game.move() to make a move. We will then want to update the view. As one possibility, we could just draw what has changed, i.e. an X or an O for the move that was just made. However, if the move won the game, then we would also need to redraw all the winning squares to highlight them in some way.

Instead, we will use a different approach: when anything changes, we will redraw everything. I recommend this approach in many graphical programs: it will usually lead to the simplest code, and to the user it will generally appear exactly the same as drawing incremental updates. On a Tkinter canvas, we can call delete('all') to delete all canvas items before redrawing.

Here is an implementation of the view:

# view

MARGIN = 50     # margin size in pixels
SQUARE = 100    # square size in pixels
BOARD_SIZE = 3 * SQUARE + 2 * MARGIN

def coord(x):
    return MARGIN + SQUARE * x

class View(Canvas):
    def __init__(self, parent):
        self.game = Game()
        super().__init__(parent, width = BOARD_SIZE, height = BOARD_SIZE)
        self.grid()
        self.bind('<Button>', self.on_click)
        self.draw()

    def line(self, x1, y1, x2, y2, **args):
        self.create_line(coord(x1), coord(y1), coord(x2), coord(y2), **args)

    def rectangle(self, x1, y1, x2, y2, **args):
        self.create_rectangle(coord(x1), coord(y1), coord(x2), coord(y2), **args)

    def oval(self, x1, y1, x2, y2, **args):
        self.create_oval(coord(x1), coord(y1), coord(x2), coord(y2), **args)

    def draw(self):
        self.delete('all')

        for i in range(1, 3):   # 1 .. 2
            self.line(i, 0, i, 3)  # vertical line
            self.line(0, i, 3, i)  # horizontal line
        
        for x, y in self.game.winning_squares:
            self.rectangle(x + 0.05, y + 0.05, x + 0.95, y + 0.95,
                           fill = 'green', width = 0)
        
        for x in range(0, 3):
            for y in range(0, 3):
                if self.game.at[x][y] == 1:    # draw an X
                    self.line(x + 0.1, y + 0.1, x + 0.9, y + 0.9, width = 3)
                    self.line(x + 0.9, y + 0.1, x + 0.1, y + 0.9, width = 3)
                elif self.game.at[x][y] == 2:   # draw an O
                    self.oval(x + 0.1, y + 0.1, x + 0.9, y + 0.9, width = 2)

    def on_click(self, event):
        if self.game.game_over:
            self.game = Game()       # start a new game
        else:
            play_x, play_y = int((event.x - MARGIN) / SQUARE), int((event.y - MARGIN) / SQUARE)
            if 0 <= play_x < 3 and 0 <= play_y < 3:     # valid square
                self.game.move(play_x, play_y)
        self.draw()

root = Tk()
root.title('tic tac toe')
View(root)
root.mainloop()

The complete implementation (model plus view) is 95 lines of Python code. Here is the program in action:

modules

A module is a collection of definitions that Python code can import and use. We've been using modules in Python's standard library for many weeks now. For example, the line

import math

lets us use functions in the math module, which is built into the standard library. This statement loads the module into memory and actually makes a variable called 'math' that points to it. Like everything else in Python, a module is an object:

>>> import math
>>> math
<module 'math' (built-in)>

After that, we can access any name defined by the module by prefixing it with the module name:

>>> math.sin(0)
0.0

Here is a brief overview of some other ways to import (most of which we have seen before). We may wish to import some of the module's names directly into our namepace, so that we can access them without a prefix. We can do that using a 'from…import' statement:

>>> from math import sin, cos
>>> sin(0) + cos(0)
1.0

We can import all of a module's names using the '*' wildcard character:

>>> from math import *
>>> log(1) + sqrt(1)
1.0

Finally, the 'import...as' statement will import a module using an alternative name of our choice:

>>> import math as m
>>> m.ceil(4.5) + m.floor(4.5)
9

writing modules

Writing modules in Python is easy. In fact, every Python source file is a module!

Let's make a source file with a couple of statistics functions. We'll call it 'stats.py':

# stats.py

def avg(nums):
    return sum(nums) / len(nums)

def variance(nums):
    a = avg(nums)
    d = avg([(n - a) ** 2 for n in nums])
    return d

Python considers this source file to be a module with the name 'stats'. Let's write another source file 'top.py' that imports this module and calls one of its functions:

# top.py
import sys
import stats

nums = [float(line) for line in sys.stdin]
a = stats.avg(nums)
v = stats.variance(nums)
print(f'avg = {a:.2f}, variance = {v:.2f}')

In this program, the first statement 'import sys' imports a built-in module, and the second statement 'import stats' imports a module defined by a Python file in the same directory.

In general, 'import' looks for modules in every directory in Python's built-in search path. If you're curious, you can see the search path in the variable sys.path. On my system it looks like this:

>>> import sys
>>> sys.path
['', '/home/adam/lib/python', '/usr/lib/python310.zip', '/usr/lib/python3.10', '/usr/lib/python3.10/lib-dynload', '/home/adam/.local/lib/python3.10/site-packages', '/usr/local/lib/python3.10/dist-packages', '/usr/lib/python3/dist-packages']
>>> 

packages

A package is a special kind of module that can contain both top-level definitions and other modules. Packages are also a unit of software distribution. In other words, it's possible (and fairly easy) to write a package of Python code and then make it available for others to install and use on their systems.

To this end, Python includes a package manager called 'pip' that can install and remove packages on your system. As a first experiment, you can run 'pip list' to see a list of packages that are currently installed. (On macOS, you will need to run 'pip3' instead of 'pip'.) When I run this command, I see output that begins like this:

$ pip list
Package                         Version
------------------------------- ---------------
appdirs                         1.4.4
attrs                           22.1.0
banking.statements.nordea       1.3.0
banking.statements.osuuspankki  1.3.4.dev0
bcrypt                          3.2.0
…

I did not explicitly install most of these packages; instead, they came as part of the base Python installation or were installed by various Python-based programs on my system.

We can easily install additional packages. Suppose that I'm looking for an implementation of a calendar widget for Tkinter, since Tkinter itself contains no such widget. By default, pip finds packages to install in an enormous repository called PyPI (the Python Package Index), which currently lists over 400,000 packages that have been contributed by thousands of users. If I go to the PyPI web site and search for 'tkinter calendar', the top result is a package called 'tkcalendar'. Let's install it:

$ pip install tkcalendar
Collecting tkcalendar
  Downloading tkcalendar-1.6.1-py3-none-any.whl (40 kB)
     |████████████████████████████████| 40 kB 3.2 MB/s 
Collecting babel
  Downloading Babel-2.9.1-py2.py3-none-any.whl (8.8 MB)
     |████████████████████████████████| 8.8 MB 5.2 MB/s 
Requirement already satisfied: pytz>=2015.7 in /usr/lib/python3/dist-packages (from babel->tkcalendar) (2021.1)
Installing collected packages: babel, tkcalendar
Successfully installed babel-2.9.1 tkcalendar-1.6.1

pip installed the 'tkcalendar' package, as well as a second package 'babel' that is a dependency of 'tkcalendar'.

Now we can import 'tkcalendar' and use functions that it provides. The following commands will display a calendar widget:

>>> import tkcalendar
>>> cal = tkcalendar.Calendar(None)
>>> cal.grid()

music in Python

As another example of using a package that is not part of Python's base library, let's write a program in Python that we can use to play music.

Many Python packages for working with audio are available. In our program we will use the open-source FluidSynth synthesizer, which can synthesize realistic-sounding musical notes based on waveforms from an input file called a soundfont file. We will use the Fluid General-MIDI Soundfont, which contains waveforms that were sampled from many actual musical instruments such as pianos, horns, violins and so on.

The Python package 'pyfluidsynth' will allow us to use FluidSynth from Python. To use it, we will first need to install FluidSynth itself, and then the pyfluidsynth package. I've provided a page with instructions for performing this installation on any operating system, as well as a summary of the methods that we'll need in the pyfluidsynth API.

In our program, we want to draw a keyboard that looks like this:

We first need to choose the size of our graphical elements. Let's decide that the keys will be 40 pixels wide and 150 pixels high. Just as in the Tic-Tac-Toe program, let's define constants for these values:

WIDTH = 40
HEIGHT = 150

We will use a Tkinter canvas to display the keyboard. Each individual key can be a rectangle canvas item. We'll draw them like this:

keys = []
for k in range(KEYS):
    keys.append(canvas.create_rectangle(k * WIDTH, 0, (k + 1) * WIDTH, HEIGHT, fill = 'white'))
for k in range(KEYS - 1):
    if (k % 7 not in [2, 6]):
        keys.append(canvas.create_rectangle(
            (k + 0.7) * WIDTH, 0, (k + 1.3) * WIDTH, HEIGHT * 0.6, fill = 'black'))

Each call to canvas.create_rectangle() returns an integer ID representing the rectangle that was drawn. We collect all of these in a list 'keys'.

When the user clicks a key, we'd like to play a sound. We must determine which key was pressed. Fortunately Tkinter makes this pretty easy. We can call the canvas's find_overlapping() method to find all canvas items that overlap a given rectangle or point. If the user clicks a black key, there will actually be two canvas items that overlap the point where the user clicked, namely the rectangle for the black key and a second rectangle for the white key that is under it. find_overlapping() returns canvas items in order of increasing Z-order, i.e. from the lowest item to the highest item. We drew the white keys before the black keys, so the black keys are on top of them, i.e. have a higher Z value. And so in this situation find_overlapping() will return a sequence containing first the white key, then the black key.

Above the keyboard we'd like to display a list box where the user can choose an instrument to play. In the soundfont that we are using, the instruments are numbered according to the General MIDI specfication, in which e.g. 1 represents an Acoustic Grand Piano and 41 represents a violin. Actually we have to be a bit careful: these are numbers in the range from 1 to 128 (intended to be displayed to users), but internally the numbers are one less, ranging from 0 to 127. Here is a file 'instruments' that lists all the General MIDI instruments. It begins like this:

Piano:
1 Acoustic Grand Piano
2 Bright Acoustic Piano
3 Electric Grand Piano
4 Honky-tonk Piano
5 Electric Piano 1
6 Electric Piano 2
7 Harpsichord
8 Clavinet

Chromatic Percussion:
9 Celesta
10 Glockenspiel
…

In our program we will read this file and use its contents to populate the list box.

Here is the complete program. To run it, you'll need to have the file 'instruments' in your current directory, as well as the soundfont file 'FluidR3_GM.sf2' (or a link to it).

import fluidsynth
import sys
from tkinter import *
from tkinter import ttk

synth = fluidsynth.Synth()

if sys.platform == 'linux':
    synth.start(device = 'hw:0')
elif sys.platform == 'win32':       # windows
    synth.start(driver = 'dsound')
else:
    synth.start()

font = synth.sfload('FluidR3_GM.sf2')
synth.program_select(0, font, 0, 0)

root = Tk()
root.title('synth')

KEYS = 22
WIDTH = 40
HEIGHT = 150

instruments = []
with open('instruments') as f:
    for line in f:
        line = line.strip()
        if line != '' and line[0].isdigit():
            s = line.index(' ')
            instruments.append(line[s + 1:])

insts = StringVar()
insts.set(instruments)

listbox = Listbox(root, height = 5, listvariable = insts)
listbox.grid(row = 0, column = 0, sticky = E)

scroll = ttk.Scrollbar(root, orient = VERTICAL, command = listbox.yview)
scroll.grid(row = 0, column = 1, sticky = [N, S, W])
listbox['yscrollcommand'] = scroll.set

canvas = Canvas(root, width = KEYS * WIDTH, height = HEIGHT)
canvas.grid(row = 1, column = 0, columnspan = 2)

keys = []
for k in range(KEYS):
    keys.append(canvas.create_rectangle(k * WIDTH, 0, (k + 1) * WIDTH, HEIGHT, fill = 'white'))
for k in range(KEYS - 1):
    if (k % 7 not in [2, 6]):
        keys.append(canvas.create_rectangle(
            (k + 0.7) * WIDTH, 0, (k + 1.3) * WIDTH, HEIGHT * 0.6, fill = 'black'))

keys.sort(key = lambda k: canvas.coords(k)[0])

def on_inst(ev):
    i = listbox.curselection()[0]
    synth.program_select(0, font, 0, i)

note = None

def on_click(ev):
    global note
    hit = canvas.find_overlapping(ev.x, ev.y, ev.x, ev.y)
    if len(hit) != 0:
        key = hit[-1]
        note = 48 + keys.index(key)
        synth.noteon(0, note, 100)

def on_up(ev):
    global note
    synth.noteoff(0, note)
    key = None

listbox.bind('<<ListboxSelect>>', on_inst)

canvas.bind('<Button>', on_click)
canvas.bind('<ButtonRelease>', on_up)

root.mainloop()

The program is about 80 lines long. It looks like this: