In many programs we may wish to display graphics.
A typical notebook or monitor today has a graphical display with a resolution of 1920 x 1080 pixels. On almost every display each pixel's color is a combination of red, green, and blue components, each of which is an unsigned 8-bit value ranging from 0 to 255. For example, this blueish color has components red = 98, green = 160, blue = 234:
Sometimes we will represent a color using a string such as "#62a0ea", in which the red, green, and blue components are represented as 2-digit hex numbers. For example, 6216 = 9810, a016 = 16010 and ea16 = 23410, so this hex string represents the same blueish color we see above.
We can use a graphics library (also called a toolkit) to perform graphical output. Such libraries will allow us to draw low-level shapes such as lines, rectangles, and circles, and may also provide higher-level widgets such as menus, buttons, and toolbars that we can use to build more complex graphical interfaces. The term GUI (= graphical user interface) usually refers to a user interface of this sort.
Some popular cross-platform graphics toolkits include GTK and Qt. These are large and complex, and are accessible from many languages including Python. Most desktop applications that come with Ubuntu and other Linux distributions use either GTK or Qt.
Additionally, there exist various Python-specific libraries including Tkinter and Pygame. Tkinter is part of the standard library in every Python installation, and includes many widgets such as menus and buttons. Pygame is a popular cross-platform library for creating real-time games in Python. Pygame doesn't come with Python, but you can easily install it using pip.
If you're writing a desktop application such as a text editor or calendar program, Tkinter is probably a good choice because its widgets will be helpful for building your program's interface. On the other hand, if you want smooth real-time graphics, it may be better to write your program in Pygame. If you're implementing a turn-based board game and don't need widgets, either library could be OK.
This week we will discuss Tkinter. I've created a Tkinter Quick Reference page that describes the Tkinter classes and methods that we will use, as well as various widget classes that we won't have time to cover. For more information, the site TkDocs has a Tkinter tutorial and also points to the Tkinter reference pages. (The book Modern Tkinter is inexpensive and is also helpful, though it seems to contain just the same text as the Tkinter tutorial in PDF format.)
As a first example, let's write a Tkinter program that displays a window with a green circle:
We will use a Canvas widget, which is a rectangular area where you can draw any graphics that you like. The canvas widget is flexible and powerful. In fact, some Tkinter programs might use only a single canvas widget inside a top-level window.
A canvas may contain various items such as lines, rectangles, polygons, ovals, text, or images. Each canvas item represents a shape or image, and has an integer ID. Each canvas item has various options which you can set when you create an item, and which you can modify later using the itemconfigure() method of the Canvas class.
Here is the program:
import tkinter as tk root = tk.Tk() # create a top-level window for the application root.title('circle') canvas = tk.Canvas(root, width = 400, height = 400) canvas.grid() canvas.create_oval(100, 100, 300, 300, fill = 'green', width = 3) root.mainloop() # run the main loop
Let's look at the program line by line. We first import the tkinter library using the convenient abbreviation "tk".
The Tk() constructor creates a top-level window for your application. Typically we store this window in a variable called "root". You can call the .title() method on the top-level window to set its title.
We next create a canvas widget and specify values for two options, namely 'width' and 'height' which determine the canvas's size. To make the canvas visible, we must call canvas.grid() to register it with the grid geometry manager (otherwise known as a layout manager), which determines the positions and sizes of widgets in a window. We will learn more about grid layout in a following section.
The call create_oval() creates an oval item, which is an ellipse (of which a circle is a special case). This program draws an oval whose bounding box is the square with (100, 100) at its upper-left corner, and (300, 300) at its lower-right corner. We specify two canvas item options, namely 'fill', the color with which to fill the oval, and 'width', the width of the outline in pixels.
Finally, we call the mainloop() method which tells Tkinter to run the main loop for our program. Typically any graphics library has such a main loop, which waits for events to occur and dispatches them to handler functions. In this first program we don't handle any events; we simply display graphics and wait for the user to close the window. Nevertheless we must still call mainloop().
Let's modify our program so that it will create a new circle each time the user clicks the mouse. We'll give each new circle a random color. Here's the updated program:
from random import randrange import tkinter as tk root = tk.Tk() # create a top-level window for the application root.title('circle') canvas = tk.Canvas(root, width = 400, height = 400) canvas.grid() def on_click(event): # choose a random color red, green, blue = randrange(256), randrange(256), randrange(256) color = f'#{red:02x}{green:02x}{blue:02x}' canvas.create_oval(event.x - 50, event.y - 50, event.x + 50, event.y + 50, fill = color, width = 3) canvas.bind('<Button>', on_click) root.mainloop() # run the main loop
In this program, on_click() is an event handler function that we want to run whenever the user clicks the mouse. We call the .bind() method to register this event handler to run in response to the '<Button>' event. (You can read about other event types in our quick reference guide.)
Every event handler function receives an argument 'event' holding an Event object with information about the event, such as the x/y coordinates of the position where the user clicked the mouse. In our event handler, we first generate a random color and encode its red/green/blue components as 2-digit hex numbers in a color string. We then create a circle with radius 50, centered at the position where the user clicked. Here's an image of the running program after I've clicked to create a few circles:
Let's look at a Tkinter program that uses more widget types. It will convert Fahrenheit temperatures into Celsius, and will look like this:
Here is the code:
import tkinter as tk from tkinter import ttk from tkinter import messagebox root = tk.Tk() root.title('converter') celsius = tk.StringVar() fahrenheit = tk.StringVar() def convert(*args): try: f = float(fahrenheit.get()) except ValueError: messagebox.showinfo(message = 'invalid temperature') fahrenheit.set('') return c = (f - 32) * 5 / 9 celsius.set(f'{c:.1f}') ttk.Label(root, text = 'Fahrenheit:').grid(row = 0, column = 0) ttk.Entry(root, textvariable = fahrenheit).grid(row = 0, column = 1) ttk.Label(root, text = 'Celsius:').grid(row = 1, column = 0) ttk.Label(root, textvariable = celsius).grid(row = 1, column = 1, sticky = tk.W) ttk.Button(root, text = 'Convert', command = convert).grid(row = 2, column = 0) for w in root.winfo_children(): w.grid_configure(padx = 5, pady = 5) root.bind('<Return>', convert) root.mainloop()
The program begins with these two lines:
import tkinter as tk from tkinter import ttk
"ttk" stands for "themed toolkit". The ttk submodule appeared around 2007, and contains newer and nicer-looking versions of various widgets (e.g. Button, Entry, Label). In general I recommend that you use the ttk versions of these widgets, as I have done in the code above.
The program also imports the messagebox module, which will allow us to display a message box later on:
from tkinter import messagebox
After that, the program creates the program's main window and gives it a title:
root = tk.Tk() root.title('converter')
The Tk class represents the main window (also called the 'root window') of a program. Traditionally we store the main window in a variable named 'root', though of course you can use any name you like.
Next, the program creates two control variables named 'fahrenheit' and 'celsius':
fahrenheit = tk.StringVar() celsius = tk.StringVar()
As we will see shortly, you can associate a control variable with a Tkinter widget, which will display the variable's value. If it's a widget where the user can enter data, then you can call the control variable's get() method to find out the data has entered. Also, you can call the set() method to give a new value to the control variable, and the widget will automatically update.
Let's skip the convert() function for now. The next part of the program constructs various widgets and adds them to the main window:
ttk.Label(root, text = 'Fahrenheit:').grid(row = 0, column = 0) ttk.Entry(root, textvariable = fahrenheit).grid(row = 0, column = 1) ttk.Label(root, text = 'Celsius:').grid(row = 1, column = 0) ttk.Label(root, textvariable = celsius).grid(row = 1, column = 1, sticky = tk.W) ttk.Button(root, text = 'Convert', command = convert).grid(row = 2, column = 0)
Each widget has various options that affect its appearance. When you call a constructor to create a widget, you can specify options using keyword arguments.
A Label widget displays a piece of text:
The text may either be static (specified via the 'text' parameter) or dynamic, in which case its value is in a control variable (specified by the 'textvariable' parameter).
An Entry widget displays a text box where the user can enter data:
It uses a control variable (specified by the 'textvariable' parameter) that holds the text in the box.
A Button widget is a pushbutton:
It runs a handler function (specified by the 'command' parameter) when the user presses the button.
We place all of these widgets in the window by calling the .grid() method. This adds them to the grid() geometry manager, which is reponsible for determining the exact placement of the widgets. (Other geometry managers exist in Tkinter, but we will not discuss them in this class.) In this geometry manager, widgets are arranged in a two-dimensional grid with row and column numbers indexed from zero.
As we saw in a previous section, you must call .grid() for every widget you create, otherwise it will not appear in the user interface.
Also notice that when we
create the label that will hold the Celsius temperature, we specify
the option 'sticky
= tk.W
'. Here, W means west, i.e.
to the left. This option causes the label to stick to the left edge
of its grid cell, which makes it line up nicely with the text in the
box above it. If we didn't specify this option, the label text would
be centered in its cell, which wouldn't look as nice.
Next, we have this loop:
for w in root.winfo_children(): w.grid_configure(padx = 5, pady = 5)
The purpose of this code is to add padding (extra blank pixels) around the edges of the widgets so they will be spaced out nicely in the window.
The winfo_children() method returns all of a widget's children. When we invoke it on the root window, we get an enumeration of all the widgets that we just created. The grid_configure() method modifies a widget's grid options. We could have included 'padx = 5, pady = 5' inside all the calls to grid() above, but it's easier to specify it for all widgets at once as we have done here. 'padx' specifies a number of blank pixels to display to a widget's left and right; 'pady' specifies the number of blank pixels above and below.
At the end of the program we call the mainloop() method, which tells Tkinter to run the main loop for our program. Typically any graphics library has such a main loop, which waits for events to occur and dispatches them to event handler functions.
Finally, let's look at the convert() function:
def convert(): try: f = float(fahrenheit.get()) except ValueError: messagebox.showerror(message = 'Invalid input') fahrenheit.set('') return c = 5 / 9 * (f - 32) celsius.set(f'{c:.1f}')
The function calls .get() to retrieve the string that the user has typed in the text box, indicating a temperature in Fahrenheit. It calls float() to convert it to a float, then converts it to a Celsius temperature, which it places in the control variable 'celsius'. That will update the Label that displays this variable's value.
If the user has not entered a valid floating-point number, the function calls messagebox.showerror() to display an error dialog, then clears the text box and exits.
A Checkbutton is a button that can be toggled on and off:
Here is a simple password generator program that uses a checkbutton to find out whether the user wants an uppercase password. It looks like this:
Here is the code:
from random import randrange import tkinter as tk from tkinter import ttk root = tk.Tk() root.title('password generator') pw = tk.StringVar() upper = tk.BooleanVar() def generate(): p = ''.join([chr(ord('a') + randrange(26)) for _ in range(10)]) print(upper.get()) if upper.get(): p = p.upper() pw.set(p) pas = ttk.Label(root, text = 'Password:').grid(row = 0, column = 0) ttk.Entry(root, textvariable = pw).grid(row = 0, column = 1) ttk.Button(root, text = 'Generate', command = generate).grid(row = 1, column = 0) ttk.Checkbutton(root, text = 'Uppercase', variable = upper).grid(row = 1, column = 1) for w in root.winfo_children(): w.grid_configure(padx = 5, pady = 5) root.mainloop()
Notice that the Checkbutton uses a control variable of type BooleanVar, which will be True if the button is checked.
A group of Radiobutton widgets allows the user to select one of several choices.
Here's a program that lets the user select a color using a group of radiobuttons. It looks like this:
Here's the code:
import tkinter as tk from tkinter import ttk from tkinter import messagebox root = tk.Tk() root.title('color selector') colors = ['Red', 'Yellow', 'Blue', 'Purple'] color = tk.StringVar() ttk.Label(root, text = 'Select a color:').grid(row = 0, column = 0) for i, c in enumerate(colors): b = ttk.Radiobutton(root, text = c, variable = color, value = c.lower()) b.grid(row = 1 + i % 2, column = i // 2, sticky = tk.W) def ok(): messagebox.showinfo(message = 'selected color = ' + color.get()) root.destroy() ttk.Button(root, text = 'OK', command = ok).grid(row = 3, column = 0) for w in root.winfo_children(): w.grid_configure(padx = 5, pady = 5) color.set('red') root.mainloop()
Note that each radio button has an option 'value' that is a string associated with the button. All the radio buttons share a single control variable 'color' that holds the value of the currently selected button. When the user presses OK, the program displays a message box with the selected color. Then it destroys the root window, which causes the program to exit.
Also notice that when we
place each radiobutton in the grid, we specify the option 'sticky
= tk.W
'. As we saw in a previous
section, this will cause the radiobuttons to stick to the left edge
of their grid cells, which makes them line up nicely.
We can use the widgets we've learned about so far to build a simple calculator application:
Here is the code:
import tkinter as tk from tkinter import ttk root = tk.Tk() root.title('calculator') x_var, y_var, res_var = tk.StringVar(), tk.StringVar(), tk.StringVar() ttk.Label(root, text = 'X:').grid(row = 0, column = 0) ttk.Label(root, text = 'Y:').grid(row = 1, column = 0) ttk.Label(root, text = 'Result:').grid(row = 5, column = 0) ttk.Entry(root, width = 10, textvariable = x_var).grid(row = 0, column = 1) ttk.Entry(root, width = 10, textvariable = y_var).grid(row = 1, column = 1) ttk.Label(root, textvariable = res_var).grid(row = 5, column = 1) ops = ['+', '-', '*', '/'] op_var = tk.StringVar() op_var.set('+') for i, o in enumerate(ops): b = ttk.Radiobutton(root, text = o, value = o, variable = op_var) b.grid(row = 2 + i // 2, column = i % 2) def compute(): x, y = int(x_var.get()), int(y_var.get()) op = op_var.get() if op == '+': res = x + y elif op == '-': res = x - y elif op == '*': res = x * y elif op == '/': res = x // y else: assert False, 'unknown operation' res_var.set(str(res)) ttk.Button(root, text = 'Compute', command = compute).grid(row = 4, column = 0) for w in root.winfo_children(): w.grid_configure(padx = 3, pady = 3) root.mainloop()
A Listbox is a list of text items that lets the user select one or more items:
Here's a program that displays a list of city names in a listbox. When the user selects a city, the program displays some information about it. It looks like this:
Here is the code:
import tkinter as tk from tkinter import ttk class City: def __init__(self, name, pop, lat, long): self.name = name self.pop = pop self.lat = lat self.long = long cities = [ City('berlin', 3677472, 52.52, 13.405), City('kiev', 2962180, 50.45, 30.523), City('prague', 1275406, 50.087, 14.421), City('vienna', 1951354, 48.2, 16.366)] root = tk.Tk() root.title('cities') city_names = [city.name for city in cities] choices = tk.StringVar(value = ' '.join(city_names)) listbox = tk.Listbox(root, listvariable = choices, height = 5, width = 10) listbox.grid(rowspan = 4) for i, t in enumerate(['Name', 'Population', 'Latitude', 'Longitude']): ttk.Label(text = t + ':').grid(row = i, column = 1, sticky = tk.E) name, pop, lat, long = tk.StringVar(), tk.StringVar(), tk.StringVar(), tk.StringVar() for i, v in enumerate([name, pop, lat, long]): ttk.Label(textvariable = v).grid(row = i, column = 2, sticky = tk.W) def on_select(ev): [n] = listbox.curselection() city = cities[n] name.set(city.name) pop.set(f'{city.pop:,d}') lat.set(city.lat) long.set(city.long) listbox.selection_set(0) on_select(None) listbox.bind('<<ListboxSelect>>', on_select) for w in root.winfo_children(): w.grid_configure(padx = 5, pady = 5) root.mainloop()
The code calls the .bind() method to specify an event handler function on_select() that will run when the user selects an item in the list box. We saw above that the Button widget has an option 'command' that holds a function to run when the button is clicked, but there is no analogous option for listboxes; we must bind the event by name. The <<ListboxSelect>> event type is specific to listboxes.
The on_select() function receives an Event object with information about the event that occurred, though we ignore that object in this program. The function retrieves the index of the currently selected item like this:
[n] = listbox.curselection()
curselection() returns a
list, because listboxes have a mode (which we're not using in this
program) in which multiple items can be selected at once. In this
program the list is guaranteed to contain only a single value, so we
can use the list assignment above to place that value in the variable
n
.
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 the game in progress. It might have a constructor Game() that constructs a new game. It could also have methods such as
player() - return the number (1 or 2) of the player whose turn it is to move
at(pos) – return the piece that is currently at position pos
move(from, to) – make a move from square from to square to
In a strict model-view architecture, model classes know nothing about the user interface. If we want to write several different user interfaces, we should be able to reuse 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. Typically the view holds a pointer to the model.
In this form of architecture, there is sometimes 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.
In my experience, a model-view architecture often 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).
As an example of model-view architecture, let's write a program that lets two players play Tic-Tac-Toe. Our view will use a Tkinter canvas widget.
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 hypothetical game states that it explores. We will see how to do this in Programming 2 next semester.)
What methods should our Game class have? We need an initializer to create a new Game. We should also have a method move(x, y) that makes a new move at a 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 has moved there. (In theory we could instead 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:
class Game: def __init__(self): self.reset() def reset(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.winner = -1 # 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.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 0 <= x < 3 and 0 <= y < 3 and self.at[x][y] == 0: # valid move 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.winner = 0 # draw return True else: return False
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
Our view class will have an attribute pointing to a Tkinter canvas. (Alternatively, we could use inheritance rather than containment; then our view class would be a subclass of the Tkinter Canvas class. In this program I think either approach could be reasonable.)
In this and many other graphical programs, it is convenient to invent a custom coordinate system for 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 automatically work with those coordinates. Tkinter does not have this feature (and neither does Pygame), 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 canvas coordinates:
def coord(x): # map user coordinates to canvas coordinates 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.
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: ... def line(self, x1, y1, x2, y2, **args): self.canvas.create_line(coord(x1), coord(y1), coord(x2), coord(y2), **args) def rectangle(self, x1, y1, x2, y2, **args): self.canvas.create_rectangle(coord(x1), coord(y1), coord(x2), coord(y2), **args) def oval(self, x1, y1, x2, y2, **args): self.canvas.create_oval(coord(x1), coord(y1), coord(x2), coord(y2), **args)
In these methods, the notation **args in each argument list causes Python to gather all keyword arguments (such as width = 3, color = 'black') into a dictionary. Then, in each method call the notation **args causes Python to explode the args dictionary into separate keyword arguments. And so the caller may pass any keyword arguments they like to our helper methods, which will pass them on to the underlying Tkinter Canvas methods.
When the user clicks we'll need convert the mouse position (in canvas coordinates) back to user coordinates, which will tell us which square was clicked. So let's add a helper function for converting in that direction:
def inv(x): # map canvas coordinates to user coordinates return (x - MARGIN) / SQUARE
Out 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 (especially those with a model-view architecture). It will usually lead to the simplest code. On a Tkinter canvas, we can call delete('all') to delete all canvas items before redrawing.
Here is a complete implementation of the view:
import tkinter as tk MARGIN = 50 # margin size in pixels SQUARE = 100 # square size in pixels BOARD_SIZE = 3 * SQUARE + 2 * MARGIN def coord(x): # map user coordinates to canvas coordinates return MARGIN + SQUARE * x def inv(x): # map canvas coordinates to user coordinates return (x - MARGIN) / SQUARE class View: def __init__(self, parent, game): self.game = game self.canvas = tk.Canvas(parent, width = BOARD_SIZE, height = BOARD_SIZE) self.canvas.grid() self.canvas.bind('<Button>', self.on_click) self.draw() def line(self, x1, y1, x2, y2, **args): self.canvas.create_line(coord(x1), coord(y1), coord(x2), coord(y2), **args) def rectangle(self, x1, y1, x2, y2, **args): self.canvas.create_rectangle(coord(x1), coord(y1), coord(x2), coord(y2), **args) def oval(self, x1, y1, x2, y2, **args): self.canvas.create_oval(coord(x1), coord(y1), coord(x2), coord(y2), **args) def draw(self): self.canvas.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.winner >= 0: self.game.reset() # start a new game else: play_x, play_y = int(inv(event.x)), int(inv(event.y)) if 0 <= play_x < 3 and 0 <= play_y < 3: # valid square self.game.move(play_x, play_y) self.draw() root = tk.Tk() root.title('tic tac toe') game = Game() view = View(root, game) root.mainloop()
The complete implementation (model plus view) is about 100 lines of Python code. Here is the program in action, where player X has just won the game: