In the comp110/lessons
directory, create a sub-directory named ls36_gui
.
__main__.py
In the ls36_gui
directory, add a file named __main__.py
(dunderscored). Copy in the following starter code:
"""Entrypoint of Game program.
To run: python -m comp110.lessons.ls36_gui
"""
from .Game import Game
from .Window import Window
model = Game()
view_controller = Window(model)
Game.py
Add another file named Game.py
and copy in the following starter code:
"""A simple number guessing game."""
import random
START_STATE = 0
LOWER_STATE = 1
HIGHER_STATE = 2
WINNING_STATE = 3
class Game:
secret: int
state: int
def __init__(self):
self.reset()
def reset(self) -> None:
self.secret = random.randint(0, 100)
self.state = START_STATE
def guess(self, number: int) -> None:
if number == self.secret:
self.state = WINNING_STATE
elif number < self.secret:
self.state = LOWER_STATE
else:
self.state = HIGHER_STATE
Window.py
Add another file named Window.py
and copy in the following starter code:
"""The main window of a number guessing game GUI."""
from tkinter import Tk, END
from .Game import Game
class Window:
model: Game
frame: Tk
def __init__(self, model: Game):
self.model = model
# Establish root window frame
self.frame = Tk()
self.frame.title("TODO")
# TODO - Add Controls Frame
# TODO - Add Results Frame
# Enter the Tk "main event loop" to begin app.
self.frame.mainloop()
InputFrame.py
Finally, a the last starter code file named InputFrame.py
, with the following starter code:
from tkinter import messagebox, Event
from tkinter import Frame, Label, Entry
from tkinter.constants import END, LEFT, RIGHT
class InputFrame:
frame: Frame
prompt: Label
text: Entry
def __init__(self, root: Tk, prompt: str):
self.frame = Frame(root)
# TODO: Add Label
# TODO: Add Entry
self.frame.pack()
In class we modified both Window.py
and InputFrame.py
. The place where we left off both, in case you were having errors, is what follows.
Window.py
"""The main window of a number guessing game GUI."""
from comp110.lessons.ls36_gui.InputFrame import InputFrame
from tkinter import Tk, END, messagebox
from comp110.lessons.ls36_gui.Game import Game, START_STATE, LOWER_STATE, HIGHER_STATE, WINNING_STATE
class Window:
model: Game
frame: Tk
input_frame: InputFrame
def __init__(self, model: Game):
self.model = model
# Establish root window
self.frame = Tk()
self.frame.title("Number Guessing Game")
self.input_frame = InputFrame(self.frame, "Guess a number...", self.handle_guess)
# TODO - Add Results Frame
# Enter the Tk "main event loop"
self.frame.mainloop()
def handle_guess(self, user_guess: int) -> None:
self.model.guess(user_guess)
if self.model.state == WINNING_STATE:
messagebox.showinfo("Winner!", "Great guess! You are correct!")
self.model.reset()
elif self.model.state == LOWER_STATE:
messagebox.showinfo("Too low!", "Your guess was too low, try again.")
else:
messagebox.showinfo("Too high", "you guessed too high!")
InputFrame.py
from tkinter import messagebox, Event
from tkinter import Frame, Label, Entry
from tkinter.constants import END, LEFT, RIGHT
from typing import Callable
class InputFrame:
frame: Frame
prompt: Label
text: Entry
handler: Callable[[int], None]
def __init__(self, root: Tk, prompt: str, handler: Callable[[int], None]):
self.handler = handler
self.frame = Frame(root)
self.prompt = Label(self.frame)
self.prompt["text"] = prompt
self.prompt.pack()
self.text = Entry(self.frame)
self.text.bind("<Return>", self.handle_input)
self.text.pack()
self.frame.pack()
def handle_input(self, event: Event) -> None:
try:
guess: int = int(self.text.get())
self.handler(guess)
except ValueError:
messagebox.showerror("Your guess", f"Invalid number: {self.text.get()}")
After the user makes a guess, their input is left in the text Entry
field of InputFrame
. This is handy for remembering what your last guess is, but when we start a new game it should reset to empty.
Let’s add some additional capability to the InputFrame
class by introducing its own reset
method:
The text
attribute is an Entry
object which has a delete
method (in addition to many others for manipulating the current text in an input field). The start index is 0
and uses the special END
constant, defined by Tk, to delete all. This kind of knowledge would be found by searching Google for “how to clear a Tk Entry in Python”.
Now, this method needs to be called after the user wins a game. So back in Window
’s handle_guess
method, try calling this method after resetting the model: self.input_frame.reset()
Play the game and win to convince yourself the text you previously entered into the Entry actually gets cleared.
Rather than popping up inforomation to the user after each guess in a window that has to be clicked away, let’s add a new kind of pane beneath the input pane that shows the outcome of a guess. When a guess is correct, we will also show the user a button they can press to play again.
Begin by adding a new file in the ls36_gui
directory named ResultsFrame.py
. It should have the following contents to begin with:
from tkinter import Frame, Label, Button
from typing import Callable
from comp110.lessons.ls36_gui.Game import Game, START_STATE, HIGHER_STATE, LOWER_STATE
class ResultsFrame:
frame: Frame
model: Game
result: Label
reset: Button
def __init__(self, root: Tk, model: Game):
self.frame = Frame(root)
self.model = model
self.label = Label(self.frame)
self.label.pack()
self.reset = Button(self.frame)
self.reset["text"] = "Reset"
self.update() # Call the update method (below) to set state.
self.frame.pack()
def update(self) -> None:
if self.model.state == START_STATE:
self.reset.pack_forget() # Hide the Reset button!
self.label["text"] = "Make a guess!"
elif self.model.state == HIGHER_STATE:
self.label["text"] = "Too high!"
elif self.model.state == LOWER_STATE:
self.label["text"] = "Too low!"
else:
self.reset.pack() # Show the Reset button!
self.label["text"] = "Correct!"
Now, we should add a ResultsFrame
object to our Window
. First, we need to import it:
Then, we establish an attribute
of Window
:
In the __init__
constructor, we must initialize the attribute:
Then, rather than having the handle_guess
method of the Window
class cause the messagebox
popups, let’s rewrite handle_guess
to update the Game
state and then call the update
function of the ResultsFrame
:
def handle_guess(self, user_guess: int) -> None:
self.model.guess(user_guess)
self.results_frame.update()
Remember the connection here. When the user makes a guess, this handle_guess
method is called. The game state is updated and then results_frame
is updated so that its label changes.
If you play the game you should see the message change and ultimately be presented with a Button
. How can we make the button click actually take some kind of action?
Each Button
has a special dictionary key attribute named "command"
. You can set the command of a button to be any function or method with 0-parameters and a return type of None
. Ultimately, we want the Window
to handle what it means for the game to be reset
, so let’s setup a method in Window
to reset the game first:
Notice each of the model and input frame’s respective reset
methods were called, followed by the results_frame
update method which is driven by the state of the Game
.
Next, we want the ResultsFrame
class to set the button’s command to call this reset
method. To do so, let’s expand the constructor of ResultsFrame
slightly.
def __init__(self, root: Tk, model: Game, reset_handler: Callable[[], None]):
self.frame = Frame(root)
self.model = model
self.label = Label(self.frame)
self.label.pack()
self.reset = Button(self.frame)
self.reset["text"] = "Reset"
self.reset["command"] = reset_handler
self.update() # Call the update method (below) to set state.
self.frame.pack()
Notice two changes: first, the third parameter added. Second the reset button’s "command"
key attribute was updated to be linked to the reset handler.
Finally, in the Window
constructor, you’ll need to update the construction of the ResultsFrame
to include a reference to the reset
method:
Now, if you play through the game, you should be able to reset it after you win and have the game go back to a start state.
Notice how the Window
, ResultsFrame
, and InputFrame
attempt to separate their concerns from one another and divide up the work of building the interface and handling user inputs. This type of strategy is encouraged for building the GUI application for the next project. It takes some time to get comfortable with, but this pattern can work really well in building toward larger applications.
This completes the tutorial. The final versions of Window
, ResultsFrame
, and InputFrame
are included below:
Window.py
"""The main window of a number guessing game GUI."""
from comp110.lessons.ls36_gui.InputFrame import InputFrame
from comp110.lessons.ls36_gui.ResultsFrame import ResultsFrame
from tkinter import Tk, END, messagebox
from comp110.lessons.ls36_gui.Game import Game, START_STATE, LOWER_STATE, HIGHER_STATE, WINNING_STATE
class Window:
model: Game
frame: Tk
input_frame: InputFrame
results_frame: ResultsFrame
def __init__(self, model: Game):
self.model = model
# Establish root window
self.frame = Tk()
self.frame.title("Number Guessing Game")
self.input_frame = InputFrame(self.frame, "Guess a number...", self.handle_guess)
self.results_frame = ResultsFrame(self.frame, model, self.reset)
# Enter the Tk "main event loop"
self.frame.mainloop()
def handle_guess(self, user_guess: int) -> None:
self.model.guess(user_guess)
self.results_frame.update()
def reset(self) -> None:
self.model.reset()
self.input_frame.reset()
self.results_frame.update()
InputFrame.py
from tkinter import messagebox, Event
from tkinter import Frame, Label, Entry
from tkinter.constants import END, LEFT, RIGHT
from typing import Callable
class InputFrame:
frame: Frame
prompt: Label
text: Entry
handler: Callable[[int], None]
def __init__(self, root: Tk, prompt: str, handler: Callable[[int], None]):
self.handler = handler
self.frame = Frame(root)
self.prompt = Label(self.frame)
self.prompt["text"] = prompt
self.prompt.pack()
self.text = Entry(self.frame)
self.text.bind("<Return>", self.handle_input)
self.text.pack()
self.frame.pack()
def handle_input(self, event: Event) -> None:
try:
guess: int = int(self.text.get())
self.handler(guess)
except ValueError:
messagebox.showerror("Your guess", f"Invalid number: {self.text.get()}")
def reset(self) -> None:
self.text.delete(0, END)
ResultsFrame.py
from tkinter import Frame, Label, Button
from typing import Callable
from comp110.lessons.ls36_gui.Game import Game, START_STATE, HIGHER_STATE, LOWER_STATE
class ResultsFrame:
frame: Frame
model: Game
result: Label
reset: Button
def __init__(self, root: Tk, model: Game, handler: Callable[[], None]):
self.frame = Frame(root)
self.model = model
self.label = Label(self.frame)
self.label.pack()
self.reset = Button(self.frame)
self.reset["text"] = "Reset"
self.reset["command"] = handler
self.update() # Call the update method (below) to set state.
self.frame.pack()
def update(self) -> None:
if self.model.state == START_STATE:
self.reset.pack_forget() # Hide the Reset button!
self.label["text"] = "Make a guess!"
elif self.model.state == HIGHER_STATE:
self.label["text"] = "Too high!"
elif self.model.state == LOWER_STATE:
self.label["text"] = "Too low!"
else:
self.reset.pack() # Show the Reset button!
self.label["text"] = "Correct!"